From 859885793f3c4357ac677e666ad445e2700e9861 Mon Sep 17 00:00:00 2001 From: Jon Laing Date: Sat, 14 Mar 2026 03:17:12 -0400 Subject: [PATCH 1/7] add SSG --- packages/create-effex/src/index.ts | 32 ++- .../create-effex/templates/ssg/package.json | 23 ++ .../create-effex/templates/ssg/src/App.ts | 22 ++ .../create-effex/templates/ssg/src/client.ts | 9 + .../create-effex/templates/ssg/src/entry.ts | 39 +++ .../create-effex/templates/ssg/src/routes.ts | 142 +++++++++++ .../create-effex/templates/ssg/vite.config.ts | 7 + packages/platform/src/Platform.ts | 233 ++++++++++++++++++ packages/platform/src/index.ts | 2 + packages/router/src/Route.test.ts | 74 ++++++ packages/router/src/Route.ts | 64 +++++ packages/router/src/index.ts | 1 + packages/vite-plugin/src/plugin.test.ts | 65 +++++ packages/vite-plugin/src/plugin.ts | 123 ++++++++- 14 files changed, 828 insertions(+), 8 deletions(-) create mode 100644 packages/create-effex/templates/ssg/package.json create mode 100644 packages/create-effex/templates/ssg/src/App.ts create mode 100644 packages/create-effex/templates/ssg/src/client.ts create mode 100644 packages/create-effex/templates/ssg/src/entry.ts create mode 100644 packages/create-effex/templates/ssg/src/routes.ts create mode 100644 packages/create-effex/templates/ssg/vite.config.ts diff --git a/packages/create-effex/src/index.ts b/packages/create-effex/src/index.ts index ac9af980..72039029 100644 --- a/packages/create-effex/src/index.ts +++ b/packages/create-effex/src/index.ts @@ -9,13 +9,14 @@ const __dirname = path.dirname(fileURLToPath(import.meta.url)); interface ProjectOptions { name: string; - template: "ssr" | "spa"; + template: "ssr" | "spa" | "ssg"; install: boolean; } const TEMPLATES = { ssr: "Full-stack SSR app with Effect HTTP server", spa: "Single-page app (client-only)", + ssg: "Static site generation (pre-rendered HTML)", }; async function main() { @@ -30,14 +31,15 @@ async function main() { const noInstall = args.includes("--no-install"); const useSSR = args.includes("--ssr"); const useSPA = args.includes("--spa"); + const useSSG = args.includes("--ssg"); let options: ProjectOptions; // If all options provided via CLI, skip prompts - if (projectNameArg && (useSSR || useSPA)) { + if (projectNameArg && (useSSR || useSPA || useSSG)) { options = { name: projectNameArg, - template: useSSR ? "ssr" : "spa", + template: useSSR ? "ssr" : useSSG ? "ssg" : "spa", install: !noInstall, }; } else { @@ -54,7 +56,7 @@ async function main() { "Name can only contain lowercase letters, numbers, and hyphens", }, { - type: useSSR || useSPA ? null : "select", + type: useSSR || useSPA || useSSG ? null : "select", name: "template", message: "Select a template:", choices: [ @@ -66,6 +68,10 @@ async function main() { title: `${pc.blue("SPA")} - ${TEMPLATES.spa}`, value: "spa", }, + { + title: `${pc.magenta("SSG")} - ${TEMPLATES.ssg}`, + value: "ssg", + }, ], initial: 0, }, @@ -86,7 +92,13 @@ async function main() { options = { name: projectNameArg || response.name, - template: useSSR ? "ssr" : useSPA ? "spa" : response.template, + template: useSSR + ? "ssr" + : useSSG + ? "ssg" + : useSPA + ? "spa" + : response.template, install: noInstall ? false : (response.install ?? true), }; } @@ -180,6 +192,16 @@ async function main() { console.log( ` ${pc.cyan("pnpm")} start ${pc.dim("# Start production server")}`, ); + } else if (options.template === "ssg") { + console.log( + ` ${pc.cyan("pnpm")} dev ${pc.dim("# Start dev server with SSR")}`, + ); + console.log( + ` ${pc.cyan("pnpm")} build ${pc.dim("# Build and generate static HTML")}`, + ); + console.log( + ` ${pc.cyan("pnpm")} preview ${pc.dim("# Preview the static site")}`, + ); } else { console.log( ` ${pc.cyan("pnpm")} dev ${pc.dim("# Start Vite dev server")}`, diff --git a/packages/create-effex/templates/ssg/package.json b/packages/create-effex/templates/ssg/package.json new file mode 100644 index 00000000..8c15a91f --- /dev/null +++ b/packages/create-effex/templates/ssg/package.json @@ -0,0 +1,23 @@ +{ + "name": "effex-app", + "version": "0.0.1", + "private": true, + "type": "module", + "scripts": { + "dev": "vite", + "build": "vite build && vite build --ssr src/entry.ts", + "preview": "vite preview" + }, + "dependencies": { + "@effex/dom": "^0.0.1", + "@effex/platform": "^0.0.1", + "@effex/router": "^0.0.1", + "@effect/platform": "^0.94.0", + "effect": "^3.19.13" + }, + "devDependencies": { + "@effex/vite-plugin": "^0.0.1", + "typescript": "~5.9.3", + "vite": "^7.0.0" + } +} diff --git a/packages/create-effex/templates/ssg/src/App.ts b/packages/create-effex/templates/ssg/src/App.ts new file mode 100644 index 00000000..22f75987 --- /dev/null +++ b/packages/create-effex/templates/ssg/src/App.ts @@ -0,0 +1,22 @@ +import { $, collect } from "@effex/dom"; +import { Link, Outlet } from "@effex/router"; + +import { router } from "./routes.js"; + +export const App = () => + $.div( + { class: "page" }, + collect( + $.nav( + {}, + collect( + Link({ href: "/" }, $.of("Home")), + $.of(" | "), + Link({ href: "/about" }, $.of("About")), + $.of(" | "), + Link({ href: "/docs/getting-started" }, $.of("Docs")), + ), + ), + $.div({}, Outlet({ router })), + ), + ); diff --git a/packages/create-effex/templates/ssg/src/client.ts b/packages/create-effex/templates/ssg/src/client.ts new file mode 100644 index 00000000..c45d5098 --- /dev/null +++ b/packages/create-effex/templates/ssg/src/client.ts @@ -0,0 +1,9 @@ +import type { Element } from "@effex/dom"; +import { hydrate } from "@effex/dom/hydrate"; + +import { App } from "./App.js"; + +hydrate( + App() as unknown as Element.Element, + document.getElementById("root")!, +); diff --git a/packages/create-effex/templates/ssg/src/entry.ts b/packages/create-effex/templates/ssg/src/entry.ts new file mode 100644 index 00000000..a9ed19fa --- /dev/null +++ b/packages/create-effex/templates/ssg/src/entry.ts @@ -0,0 +1,39 @@ +/** + * SSG entry point. + * + * Exports the router, app, and document config for buildStaticSite(). + * Also exports a `render` function for the dev server. + */ + +import { HttpApp, HttpRouter } from "@effect/platform"; + +import { Platform } from "@effex/platform"; + +import { App } from "./App.js"; +import { router } from "./routes.js"; + +// Used by buildStaticSite() at build time +export { router }; +export const app = App; +export const document = { + title: "Effex App", + scripts: ["/src/client.ts"], + styles: ["/styles.css"], +}; + +// Used by the dev server during development +const effexRoutes = Platform.toHttpRoutes(router, { + app: App, + document: { + title: "Effex App", + scripts: ["/src/client.ts"], + styles: ["/styles.css"], + }, +}); + +const httpApp = HttpRouter.empty.pipe(HttpRouter.concat(effexRoutes)); +const { handler } = HttpApp.toWebHandlerLayer(httpApp); + +export async function render(request: Request): Promise { + return handler(request); +} diff --git a/packages/create-effex/templates/ssg/src/routes.ts b/packages/create-effex/templates/ssg/src/routes.ts new file mode 100644 index 00000000..fe69a978 --- /dev/null +++ b/packages/create-effex/templates/ssg/src/routes.ts @@ -0,0 +1,142 @@ +import { Effect, Schema } from "effect"; + +import { $, collect } from "@effex/dom"; +import { Link, Route, Router } from "@effex/router"; + +// ============================================================================= +// Home page (static, no params) +// ============================================================================= + +const HomeRoute = Route.make("/").pipe( + Route.static({ + load: () => + Effect.succeed({ + title: "Welcome to Effex", + description: "A reactive UI framework built on Effect.ts primitives.", + }), + render: (data) => + $.div( + {}, + collect( + $.h1({}, $.of(data.title)), + $.p({}, $.of(data.description)), + $.div( + { class: "card" }, + collect( + $.h2({}, $.of("Features")), + $.ul( + {}, + collect( + $.li({}, $.of("Static site generation")), + $.li({}, $.of("Pre-rendered HTML pages")), + $.li({}, $.of("Built on Effect.ts")), + $.li({}, $.of("Type-safe routing")), + ), + ), + ), + ), + ), + ), + }), +); + +// ============================================================================= +// About page (static, no params) +// ============================================================================= + +const AboutRoute = Route.make("/about").pipe( + Route.static({ + load: () => Effect.succeed({ title: "About" }), + render: (data) => + $.div( + {}, + collect( + $.h1({}, $.of(data.title)), + $.p( + {}, + $.of( + "This is a statically generated Effex site. Every page is pre-rendered at build time.", + ), + ), + $.div({ class: "card" }, Link({ href: "/" }, $.of("Back to Home"))), + ), + ), + }), +); + +// ============================================================================= +// Docs pages (static with dynamic params) +// ============================================================================= + +const docs: Record = { + "getting-started": { + title: "Getting Started", + content: + "Welcome to the Effex documentation. Edit src/routes.ts to add your own content.", + }, + routing: { + title: "Routing", + content: + "Effex uses a type-safe router built on Effect.ts. Define routes with Route.make() and compose them with Router.", + }, +}; + +const DocsRoute = Route.make("/docs/:slug").pipe( + Route.params(Schema.Struct({ slug: Schema.String })), + Route.static({ + paths: () => Effect.succeed(Object.keys(docs).map((slug) => ({ slug }))), + load: ({ params }) => + Effect.succeed( + docs[params.slug] ?? { + title: "Not Found", + content: "This page does not exist.", + }, + ), + render: (data) => + $.div( + {}, + collect( + $.h1({}, $.of(data.title)), + $.p({}, $.of(data.content)), + $.div( + { class: "card" }, + collect( + $.h3({}, $.of("Documentation")), + $.ul( + {}, + collect( + $.li( + {}, + Link( + { href: "/docs/getting-started" }, + $.of("Getting Started"), + ), + ), + $.li({}, Link({ href: "/docs/routing" }, $.of("Routing"))), + ), + ), + ), + ), + ), + ), + }), +); + +// ============================================================================= +// Router +// ============================================================================= + +export const router = Router.empty.pipe( + Router.concat(HomeRoute), + Router.concat(AboutRoute), + Router.concat(DocsRoute), + Router.fallback(() => + $.div( + {}, + collect( + $.h1({}, $.of("404 — Not Found")), + Link({ href: "/" }, $.of("Go Home")), + ), + ), + ), +); diff --git a/packages/create-effex/templates/ssg/vite.config.ts b/packages/create-effex/templates/ssg/vite.config.ts new file mode 100644 index 00000000..acf6a9a6 --- /dev/null +++ b/packages/create-effex/templates/ssg/vite.config.ts @@ -0,0 +1,7 @@ +import { defineConfig } from "vite"; + +import { effexPlatform } from "@effex/vite-plugin"; + +export default defineConfig({ + plugins: [effexPlatform({ mode: "ssg", entry: "src/entry.ts" })], +}); diff --git a/packages/platform/src/Platform.ts b/packages/platform/src/Platform.ts index 3dca95f1..e0214214 100644 --- a/packages/platform/src/Platform.ts +++ b/packages/platform/src/Platform.ts @@ -13,6 +13,9 @@ * @module */ +import * as fs from "node:fs/promises"; +import * as path from "node:path"; + import { HttpRouter, HttpServerRequest, @@ -593,6 +596,235 @@ export const makeClientLayer = ( return Layer.mergeAll(navLayer, dataProviderLayer); }; +// ============================================================================= +// Static Site Generation +// ============================================================================= + +export interface BuildStaticSiteOptions { + /** The router containing routes to build */ + readonly router: EffexRouter; + /** + * Root app component. If provided, each page renders through this + * (same component tree the client would hydrate). + */ + readonly app?: () => import("@effex/dom").Element.Element< + HTMLElement | SVGElement + >; + /** Document generation options (title, scripts, styles) */ + readonly document?: DocumentOptions; + /** Output directory for generated files */ + readonly outDir: string; + /** + * Additional layers to provide to loaders and render functions. + * Use this for services like filesystem, markdown parsers, etc. + */ + // eslint-disable-next-line @typescript-eslint/no-explicit-any + readonly layers?: Layer.Layer; +} + +interface StaticPage { + readonly url: string; + readonly html: string; +} + +/** + * Build a static site from a router's `Route.static` routes. + * + * Enumerates all static routes, runs their loaders, renders to HTML, + * and writes the output to `outDir`. + * + * @example + * ```ts + * await Platform.buildStaticSite({ + * router, + * app: App, + * document: { + * title: "My Docs", + * scripts: ["/assets/client.js"], + * styles: ["/assets/styles.css"], + * }, + * outDir: "dist", + * layers: Layer.mergeAll(FileSystemLive, MarkdownServiceLive), + * }); + * ``` + */ +export const buildStaticSite = ( + options: BuildStaticSiteOptions, +): Promise => { + const { router, outDir } = options; + const render = renderToString as ( + element: unknown, + ) => Effect.Effect; + + const program = Effect.gen(function* () { + // Collect all pages to render + const pages: Array<{ + url: string; + route: RouteType; + params: Record; + }> = []; + + for (const route of router.routes) { + const staticConfig = (route as any)._staticConfig; + if (!staticConfig) continue; + + // Get all param sets for this route + const paramSets: unknown[] = yield* ( + staticConfig.paths as () => Effect.Effect + )(); + + for (const params of paramSets) { + const url = substituteParams( + route.path, + params as Record, + ); + pages.push({ + url, + route: route as RouteType< + string, + unknown, + unknown, + unknown, + unknown, + unknown + >, + params: params as Record, + }); + } + } + + // Render all pages concurrently + const rendered: StaticPage[] = yield* Effect.forEach( + pages, + (page) => + Effect.gen(function* () { + const staticConfig = (page.route as any)._staticConfig; + + // Run the loader + const data = yield* ( + staticConfig.load as (args: { + params: unknown; + }) => Effect.Effect + )({ + params: page.params, + }); + + // Build route data for hydration + const routeData: RouteDataService = { + data, + loaderPath: page.url, + actions: {}, + }; + + // Navigation layer for this page + const navLayer = Navigation.makeLayer( + router as EffexRouter, + { + initialPath: page.url, + initialSearch: "", + }, + ); + + // RouteDataProvider that returns pre-computed data + const routeDataProviderLayer = Layer.succeed(RouteDataProvider, { + getRouteData: () => Effect.succeed(routeData), + }); + + // AsyncCache for SSR + const asyncCacheLayer = Layer.succeed(AsyncCache, makeAsyncCache()); + + const ssrLayers = Layer.mergeAll( + navLayer, + routeDataProviderLayer, + asyncCacheLayer, + ); + + // Render to HTML + let html: string; + if (options.app) { + html = yield* render(options.app()).pipe(Effect.provide(ssrLayers)); + } else { + const element = page.route.render(data); + html = yield* render(element).pipe(Effect.provide(ssrLayers)); + } + + // Wrap in document shell + const hydrationData: Record = { + data, + actions: {}, + }; + const fullHtml = generateDocument( + html, + hydrationData, + options.document, + ); + + return { url: page.url, html: fullHtml } as StaticPage; + }), + { concurrency: 10 }, + ); + + // Render 404 page from router fallback + if (router.fallback) { + const navLayer = Navigation.makeLayer(router as EffexRouter, { + initialPath: "/404", + initialSearch: "", + }); + const routeDataProviderLayer = Layer.succeed(RouteDataProvider, { + getRouteData: () => + Effect.succeed({ data: undefined, loaderPath: "/404", actions: {} }), + }); + const asyncCacheLayer = Layer.succeed(AsyncCache, makeAsyncCache()); + const ssrLayers = Layer.mergeAll( + navLayer, + routeDataProviderLayer, + asyncCacheLayer, + ); + + let fallbackHtml: string; + if (options.app) { + fallbackHtml = yield* render(options.app()).pipe( + Effect.provide(ssrLayers), + ); + } else { + fallbackHtml = yield* render(router.fallback()).pipe( + Effect.provide(ssrLayers), + ); + } + + const fullHtml = generateDocument(fallbackHtml, {}, options.document); + rendered.push({ url: "/__404__", html: fullHtml }); + } + + // Write all files to disk + yield* Effect.forEach( + rendered, + (page) => + Effect.promise(async () => { + const filePath = + page.url === "/__404__" + ? path.join(outDir, "404.html") + : page.url === "/" || page.url === "" + ? path.join(outDir, "index.html") + : path.join(outDir, page.url, "index.html"); + + await fs.mkdir(path.dirname(filePath), { recursive: true }); + await fs.writeFile(filePath, page.html, "utf-8"); + }), + { concurrency: 10 }, + ); + + console.log(`[SSG] Built ${rendered.length} pages to ${outDir}`); + }); + + // Run the program with user-provided layers + const withLayers = options.layers + ? Effect.provide(program, options.layers) + : program; + + return Effect.runPromise(withLayers as Effect.Effect); +}; + // ============================================================================= // Namespace // ============================================================================= @@ -606,4 +838,5 @@ export const Platform = { generateDocument, toHttpRoutes, makeClientLayer, + buildStaticSite, }; diff --git a/packages/platform/src/index.ts b/packages/platform/src/index.ts index 318d277e..7f59d08d 100644 --- a/packages/platform/src/index.ts +++ b/packages/platform/src/index.ts @@ -6,6 +6,8 @@ export { generateDocument, toHttpRoutes, makeClientLayer, + buildStaticSite, type DocumentOptions, type ToHttpRoutesOptions, + type BuildStaticSiteOptions, } from "./Platform.js"; diff --git a/packages/router/src/Route.test.ts b/packages/router/src/Route.test.ts index fba9a3f1..0d09bca0 100644 --- a/packages/router/src/Route.test.ts +++ b/packages/router/src/Route.test.ts @@ -210,6 +210,80 @@ describe("Route.get", () => { }); }); +describe("Route.static", () => { + it("stores static config with paths and load", () => { + const paths = () => Effect.succeed([{ slug: "a" }, { slug: "b" }]); + const load = ({ params }: { params: { slug: string } }) => + Effect.succeed({ content: `Page ${params.slug}` }); + const renderFn = (data: { content: string }) => + Effect.succeed(document.createElement("div")); + + const route = Route.make("/docs/:slug").pipe( + Route.params(Schema.Struct({ slug: Schema.String })), + Route.static({ paths, load, render: renderFn }), + ); + + expect(route._staticConfig).not.toBeNull(); + expect(route._staticConfig!.paths).toBe(paths); + expect(route._staticConfig!.load).toBe(load); + }); + + it("sets the render function", async () => { + const div = document.createElement("div"); + const route = Route.make("/about").pipe( + Route.static({ + load: () => Effect.succeed({ title: "About" }), + render: () => Effect.succeed(div), + }), + ); + + const result = await Effect.runPromise(route.render({ title: "About" })); + expect(result).toBe(div); + }); + + it("defaults paths to a single empty param set when omitted", async () => { + const route = Route.make("/about").pipe( + Route.static({ + load: () => Effect.succeed({ title: "About" }), + render: () => Effect.succeed(document.createElement("div")), + }), + ); + + const paramSets = await Effect.runPromise(route._staticConfig!.paths()); + expect(paramSets).toEqual([{}]); + }); + + it("does not set _loader (loaders are for SSR)", () => { + const route = Route.make("/about").pipe( + Route.static({ + load: () => Effect.succeed({}), + render: () => Effect.succeed(document.createElement("div")), + }), + ); + + expect(route._loader).toBeNull(); + }); + + it("passes typed data from load to render", async () => { + const route = Route.make("/docs/:slug").pipe( + Route.params(Schema.Struct({ slug: Schema.String })), + Route.static({ + paths: () => Effect.succeed([{ slug: "test" }]), + load: ({ params }) => + Effect.succeed({ content: `Page ${params.slug}` }), + render: (data) => Effect.succeed(data.content), + }), + ); + + // Simulate the build flow: load data, then pass to render + const data = await Effect.runPromise( + route._staticConfig!.load({ params: { slug: "hello" } }), + ); + const result = await Effect.runPromise(route.render(data)); + expect(result).toBe("Page hello"); + }); +}); + describe("Route.post", () => { it("stores a post handler", () => { const handler = (body: unknown) => Effect.succeed({ ok: true }); diff --git a/packages/router/src/Route.ts b/packages/router/src/Route.ts index 4fd4e57e..d6fb62fa 100644 --- a/packages/router/src/Route.ts +++ b/packages/router/src/Route.ts @@ -242,6 +242,17 @@ export interface Route< * Stored opaquely; the router never executes them. */ readonly _handlers: ReadonlyArray; + /** + * Static site generation config — set by `Route.static`. + * Contains `paths` (enumerate param sets) and `load` (fetch data for one page). + * Stored opaquely; only consumed by `Platform.buildStaticSite()`. + */ + readonly _staticConfig: { + readonly paths: () => Effect.Effect; + readonly load: (args: { + params: unknown; + }) => Effect.Effect; + } | null; } // ============================================================================= @@ -320,6 +331,7 @@ export const make = ( searchParams: Effect.map(ParamsTag, (ctx) => ctx.searchParams), _loader: null, _handlers: [], + _staticConfig: null, }); return route; @@ -507,6 +519,56 @@ export const get = }); }; +/** + * Static site generation config for a route. + * + * Provides `paths` to enumerate all param sets at build time, + * `load` to fetch data for each page, and `render` to produce the element. + * + * Mutually exclusive with `Route.get` and `Route.render` — all three + * consume `NoRenderError`. + * + * @example + * ```ts + * const DocRoute = Route.make("/docs/:slug").pipe( + * Route.params(Schema.Struct({ slug: Schema.String })), + * Route.static({ + * paths: () => Effect.gen(function* () { + * const files = yield* glob("docs/*.md"); + * return files.map(f => ({ slug: basename(f, ".md") })); + * }), + * load: ({ params }) => Effect.gen(function* () { + * const raw = yield* readFile(`docs/${params.slug}.md`); + * return parseMarkdown(raw); + * }), + * render: (data) => DocPage(data), + * }), + * ); + * ``` + */ +export const static_ = + (config: { + readonly paths?: () => Effect.Effect; + readonly load: (args: { params: P }) => Effect.Effect; + readonly render: ( + data: A, + ) => Element.Element; + }) => + ( + route: Route, + ): [NoRenderError] extends [E] + ? Route | E3, R | R3> + : never => { + return Object.assign(Object.create(RouteProto), { + ...route, + render: config.render, + _staticConfig: { + paths: config.paths ?? (() => Effect.succeed([{} as P])), + load: config.load, + }, + }); + }; + /** * Add a POST mutation handler to the route. * @@ -807,6 +869,7 @@ export const lazy = ( searchParams: Effect.map(ParamsTag, (ctx) => ctx.searchParams), _loader: null, _handlers: [], + _staticConfig: null, // Store the module loader for the router to use _load: load, }); @@ -857,6 +920,7 @@ export const Route = { make, render, get, + static: static_, post, put, delete: del, diff --git a/packages/router/src/index.ts b/packages/router/src/index.ts index 4ac97540..94f250ec 100644 --- a/packages/router/src/index.ts +++ b/packages/router/src/index.ts @@ -12,6 +12,7 @@ export { rawParams as routeRawParams, withGuard as routeWithGuard, withAnimation as routeWithAnimation, + static_ as routeStatic, lazy as lazyRoute, isRoute, routeSpecificity, diff --git a/packages/vite-plugin/src/plugin.test.ts b/packages/vite-plugin/src/plugin.test.ts index 7e599ff4..ca80e80c 100644 --- a/packages/vite-plugin/src/plugin.test.ts +++ b/packages/vite-plugin/src/plugin.test.ts @@ -164,4 +164,69 @@ export const FeedRoute = Route.make("/").pipe( expect(result).not.toContain("fetchData"); }); }); + + describe("Route.static stripping", () => { + it("strips static config with paths, load, and render", () => { + const input = `Route.static({ + paths: () => Effect.gen(function* () { + const files = yield* glob("docs/*.md"); + return files.map(f => ({ slug: f })); + }), + load: ({ params }) => Effect.gen(function* () { + return yield* readFile(params.slug); + }), + render: (data) => DocPage(data), +})`; + const result = stripServerCode(input); + expect(result).toContain("Route.render("); + expect(result).toContain("DocPage(data)"); + expect(result).not.toContain("Route.static"); + expect(result).not.toContain("glob"); + expect(result).not.toContain("readFile"); + }); + + it("strips static config without paths (no dynamic params)", () => { + const input = `Route.static({ + load: () => Effect.gen(function* () { + return yield* readFile("about.md"); + }), + render: (data) => AboutPage(data), +})`; + const result = stripServerCode(input); + expect(result).toContain("Route.render("); + expect(result).toContain("AboutPage(data)"); + expect(result).not.toContain("Route.static"); + expect(result).not.toContain("readFile"); + }); + + it("handles Route.static in a pipeline", () => { + const input = `const DocRoute = Route.make("/docs/:slug").pipe( + Route.params(Schema.Struct({ slug: Schema.String })), + Route.static({ + paths: () => Effect.succeed([{ slug: "intro" }]), + load: ({ params }) => fetchDoc(params.slug), + render: (data) => DocPage(data), + }), +);`; + const result = stripServerCode(input); + expect(result).toContain("Route.render("); + expect(result).toContain("DocPage(data)"); + expect(result).not.toContain("Route.static"); + expect(result).not.toContain("fetchDoc"); + }); + + it("handles multiple Route.static calls", () => { + const input = ` +const A = Route.make("/a").pipe( + Route.static({ load: () => Effect.succeed("a"), render: (d) => PageA(d) }), +); +const B = Route.make("/b").pipe( + Route.static({ load: () => Effect.succeed("b"), render: (d) => PageB(d) }), +);`; + const result = stripServerCode(input); + expect(result).not.toContain("Route.static"); + expect(result).toContain("PageA(d)"); + expect(result).toContain("PageB(d)"); + }); + }); }); diff --git a/packages/vite-plugin/src/plugin.ts b/packages/vite-plugin/src/plugin.ts index 7ff43e63..279335ed 100644 --- a/packages/vite-plugin/src/plugin.ts +++ b/packages/vite-plugin/src/plugin.ts @@ -7,8 +7,10 @@ import type { Plugin, ViteDevServer } from "vite"; */ export interface EffexPlatformOptions { /** - * Path to the SSR entry module that exports a `render` function. - * The render function should have the signature: (request: Request) => Promise + * Path to the SSR/SSG entry module. + * + * In SSR mode: exports a `render(request: Request) => Promise` function. + * In SSG mode: exports `{ router, app?, document?, layers? }` for static site generation. * * When provided, the plugin runs an SSR dev server with HMR in dev mode. * When omitted, only the server-code stripping transform is applied. @@ -16,6 +18,16 @@ export interface EffexPlatformOptions { * @example "src/vite-entry.ts" */ readonly entry?: string; + /** + * Build mode. + * + * - `"ssr"` (default) — Standard SSR with live server + * - `"ssg"` — Static site generation. After `vite build`, runs + * `Platform.buildStaticSite()` to pre-render all `Route.static` routes. + * + * In dev mode, both modes behave the same (SSR dev server with HMR). + */ + readonly mode?: "ssr" | "ssg"; /** * File patterns to apply the server-code stripping transform to. * Defaults to all .ts/.tsx/.js/.jsx files. @@ -60,8 +72,10 @@ export interface EffexPlatformOptions { export const effexPlatform = (options: EffexPlatformOptions = {}): Plugin => { const include = options.include ?? /\.(tsx?|jsx?)$/; const exclude = options.exclude; + const mode = options.mode ?? "ssr"; let isSsr = false; let root: string; + let outDir: string; let entryPath: string | null = null; return { @@ -69,6 +83,7 @@ export const effexPlatform = (options: EffexPlatformOptions = {}): Plugin => { configResolved(config) { root = config.root; + outDir = path.resolve(root, config.build?.outDir ?? "dist"); isSsr = !!config.build?.ssr; if (options.entry) { entryPath = path.resolve(root, options.entry); @@ -92,7 +107,8 @@ export const effexPlatform = (options: EffexPlatformOptions = {}): Plugin => { !code.includes("Route.get") && !code.includes("Route.post") && !code.includes("Route.put") && - !code.includes("Route.del") + !code.includes("Route.del") && + !code.includes("Route.static") ) { return null; } @@ -219,6 +235,48 @@ export const effexPlatform = (options: EffexPlatformOptions = {}): Plugin => { }); }; }, + + // ------------------------------------------------------------------------- + // SSG build (production only, when mode is "ssg") + // ------------------------------------------------------------------------- + + async closeBundle() { + if (mode !== "ssg" || !entryPath || isSsr) return; + + try { + // Dynamically import the built SSG entry. + // The entry must export: { router, app?, document?, layers? } + // The SSR build should have already been run (vite build --ssr) + // and the entry compiled. We import the built version. + // Dynamic import — @effex/platform is an optional peer dependency + // only needed for SSG mode at build time + const platformModule = "@effex/platform"; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const { buildStaticSite } = (await import(platformModule)) as any; + + // Try to load from the built output first, fall back to source + const entryModule = await import( + /* @vite-ignore */ path.resolve(root, entryPath) + ); + + if (!entryModule.router) { + throw new Error( + `SSG entry "${options.entry}" must export a "router"`, + ); + } + + await buildStaticSite({ + router: entryModule.router, + app: entryModule.app, + document: entryModule.document, + outDir, + layers: entryModule.layers, + }); + } catch (e) { + console.error("[effex-platform] SSG build failed:", e); + throw e; + } + }, }; }; @@ -238,6 +296,7 @@ export const stripServerCode = (code: string): string => { let result = code; result = stripLoaders(result); result = stripHandlers(result); + result = stripStaticConfig(result); return result; }; @@ -315,6 +374,64 @@ const stripHandlers = (code: string): string => { return result; }; +/** + * Strip `Route.static(config)` to `Route.render(config.render)` in client builds. + * The `paths` and `load` functions are server-only (build-time), so the client + * only needs the `render` function for hydration. + * + * Transforms: + * - `Route.static({ paths: ..., load: ..., render: (data) => El(data) })` + * → `Route.render((data) => El(data))` + * - `Route.static({ load: ..., render: (data) => El(data) })` + * → `Route.render((data) => El(data))` + */ +const stripStaticConfig = (code: string): string => { + const pattern = /Route\.static\s*\(/g; + let result = code; + let match: RegExpExecArray | null; + let offset = 0; + + pattern.lastIndex = 0; + + while ((match = pattern.exec(code)) !== null) { + const callStart = match.index + offset; + const argsStart = callStart + match[0].length; + + // Find the full config object argument + const configEnd = findArgEnd(result, argsStart); + if (configEnd === -1) continue; + + const configStr = result.slice(argsStart, configEnd); + + // Extract the render function value from the config object. + // Look for `render:` or `render :` followed by the function value. + const renderMatch = configStr.match(/\brender\s*:\s*/); + if (!renderMatch || renderMatch.index === undefined) continue; + + const renderValueStart = renderMatch.index + renderMatch[0].length; + const renderValueEnd = findArgEnd(configStr, renderValueStart); + if (renderValueEnd === -1) continue; + + const renderFn = configStr.slice(renderValueStart, renderValueEnd).trim(); + + // Replace `Route.static({ ... })` with `Route.render(() => renderFn(undefined))` + // But actually, Route.render takes `() => Element`, while Route.static's render + // takes `(data) => Element`. Since there's no loader data on the client, + // we wrap it to pass undefined. + const replacement = `Route.render(() => (${renderFn})(undefined))`; + const fullCallEnd = configEnd + 1; // +1 for closing paren of Route.static(...) + const before = result.slice(0, callStart); + const after = result.slice(fullCallEnd); + const oldLen = fullCallEnd - callStart; + result = before + replacement + after; + offset += replacement.length - oldLen; + + pattern.lastIndex = match.index + match[0].length; + } + + return result; +}; + /** * Find the end position of a single argument starting at `start`. * Handles nested parens, braces, brackets, template literals, and strings. From 76137b65c1ec59455ab75eb38e56e3767b315c4a Mon Sep 17 00:00:00 2001 From: Jon Laing Date: Sat, 14 Mar 2026 04:48:37 -0400 Subject: [PATCH 2/7] docs site! --- apps/docs/content/effect-in-2-minutes.md | 215 ++++++ apps/docs/content/todo-app/00-introduction.md | 76 +++ .../content/todo-app/01-getting-started.md | 117 ++++ .../content/todo-app/02-your-first-element.md | 243 +++++++ .../todo-app/03-making-it-interactive.md | 215 ++++++ .../todo-app/04-building-the-todo-list.md | 245 +++++++ .../todo-app/05-toggling-and-updating.md | 219 ++++++ .../content/todo-app/06-adding-new-todos.md | 232 +++++++ .../docs/content/todo-app/07-derived-state.md | 239 +++++++ .../todo-app/08-conditional-rendering.md | 249 +++++++ .../content/todo-app/09-deleting-todos.md | 153 +++++ apps/docs/content/todo-app/10-persistence.md | 314 +++++++++ apps/docs/index.html | 12 + apps/docs/package.json | 25 + apps/docs/src/client.ts | 9 + apps/docs/src/content.ts | 192 ++++++ apps/docs/src/entry.ts | 38 ++ apps/docs/src/generated/routes.ts | 18 - apps/docs/src/layout.ts | 10 + apps/docs/src/routes.ts | 147 ++++ apps/docs/src/styles.css | 288 ++++++++ apps/docs/tsconfig.json | 14 + apps/docs/vite.config.ts | 7 + benchmarks/derived.bench.ts | 174 ----- benchmarks/dom.bench.ts | 186 ------ benchmarks/list.bench.ts | 117 ---- benchmarks/profile.bench.ts | 165 ----- benchmarks/signal.bench.ts | 118 ---- benchmarks/virtual-list.bench.ts | 151 ----- package.json | 2 +- packages/platform/src/Platform.ts | 6 +- packages/vite-plugin/package.json | 6 + packages/vite-plugin/src/plugin.ts | 4 +- pnpm-lock.yaml | 630 ++---------------- pnpm-workspace.yaml | 1 + typedoc.json | 4 +- 36 files changed, 3315 insertions(+), 1526 deletions(-) create mode 100644 apps/docs/content/effect-in-2-minutes.md create mode 100644 apps/docs/content/todo-app/00-introduction.md create mode 100644 apps/docs/content/todo-app/01-getting-started.md create mode 100644 apps/docs/content/todo-app/02-your-first-element.md create mode 100644 apps/docs/content/todo-app/03-making-it-interactive.md create mode 100644 apps/docs/content/todo-app/04-building-the-todo-list.md create mode 100644 apps/docs/content/todo-app/05-toggling-and-updating.md create mode 100644 apps/docs/content/todo-app/06-adding-new-todos.md create mode 100644 apps/docs/content/todo-app/07-derived-state.md create mode 100644 apps/docs/content/todo-app/08-conditional-rendering.md create mode 100644 apps/docs/content/todo-app/09-deleting-todos.md create mode 100644 apps/docs/content/todo-app/10-persistence.md create mode 100644 apps/docs/index.html create mode 100644 apps/docs/package.json create mode 100644 apps/docs/src/client.ts create mode 100644 apps/docs/src/content.ts create mode 100644 apps/docs/src/entry.ts delete mode 100644 apps/docs/src/generated/routes.ts create mode 100644 apps/docs/src/layout.ts create mode 100644 apps/docs/src/routes.ts create mode 100644 apps/docs/src/styles.css create mode 100644 apps/docs/tsconfig.json create mode 100644 apps/docs/vite.config.ts delete mode 100644 benchmarks/derived.bench.ts delete mode 100644 benchmarks/dom.bench.ts delete mode 100644 benchmarks/list.bench.ts delete mode 100644 benchmarks/profile.bench.ts delete mode 100644 benchmarks/signal.bench.ts delete mode 100644 benchmarks/virtual-list.bench.ts diff --git a/apps/docs/content/effect-in-2-minutes.md b/apps/docs/content/effect-in-2-minutes.md new file mode 100644 index 00000000..a27e8a7f --- /dev/null +++ b/apps/docs/content/effect-in-2-minutes.md @@ -0,0 +1,215 @@ +--- +title: "Effect in 2 Minutes" +description: "A quick mental model for Effect.ts—think Promises with superpowers" +order: 1 +--- + +# Effect in 2 Minutes + +You don't need to be an Effect expert to use Effex. This page gives you just enough to be productive. + +## The Mental Model: Promises with Superpowers + +If you know Promises, you already understand 80% of Effect. + +| Promise | Effect | +|---------|--------| +| `Promise` | `Effect` | +| Value that will resolve | Value that will resolve | +| Might reject (untyped) | Might fail with `E` (typed!) | +| — | Might need `R` (dependencies) | + +The key difference: Effect tracks **errors** and **requirements** in the type system. + +## Pipeline Style: .then → .pipe + +You can chain Promises with `.then()`. Effect uses `.pipe()` with operators: + +```typescript +// Promise pipeline +fetchUser(id) + .then(user => user.profile) + .then(profile => profile.name) + .catch(err => "Unknown"); + +// Effect pipeline +fetchUser(id).pipe( + Effect.map(user => user.profile), + Effect.map(profile => profile.name), + Effect.catchAll(() => Effect.succeed("Unknown")) +); +``` + +| Promise | Effect | +|---------|--------| +| `.then(a => b)` | `Effect.map(a => b)` | +| `.then(a => promiseB)` | `Effect.flatMap(a => effectB)` | +| `.catch(handler)` | `Effect.catchAll(handler)` | + +The difference? With Effect, **errors are typed**. The compiler knows exactly what can fail. + +## Generator Style: async/await → Effect.gen/yield* + +Just like `async/await` made Promise chains readable, `Effect.gen` does the same: + +```typescript +// async/await +async function fetchUser(id: string): Promise { + const response = await fetch(`/users/${id}`); // errors? who knows! + const user = await response.json(); + return user; +} + +// Effect.gen +const fetchUser = (id: string): Effect => + Effect.gen(function* () { + const response = yield* httpGet(`/users/${id}`); // HttpError in type! + const user = yield* parseJson(response); + return user; + }); +``` + +- `function*` makes it a generator (required for `yield*`) +- `yield*` unwraps the Effect, like `await` unwraps a Promise +- **The error type is visible**—no surprise runtime crashes + +## Why Not Just Use Promises? + +This isn't arbitrary. Effects solve real problems: + +**1. Errors disappear with Promises** +```typescript +// Promise: errors vanish into the void +async function getUser(): Promise { + return fetch("/user").then(r => r.json()); // Can throw! But type says Promise +} + +// Effect: errors are tracked +const getUser: Effect = ... // Compiler enforces handling +``` + +**2. Effects are lazy, Promises are eager** +```typescript +// Promise: starts immediately when created +const promise = fetch("/api"); // Network request fires NOW + +// Effect: describes what to do, runs when you say so +const effect = httpGet("/api"); // Nothing happens yet +Effect.runPromise(effect); // NOW it runs +``` + +This laziness lets Effex render the same component on server or client, cancel unnecessary work, and batch operations. + +**3. Dependencies are explicit** +```typescript +// Promise: where does logger come from? Global? Import? Magic? +async function saveUser(user: User) { + logger.info("Saving user"); // ??? + await db.save(user); // ??? +} + +// Effect: dependencies declared in the type +const saveUser = (user: User): Effect => ... +// ↑ must be provided! +``` + +## Error Handling + +Effect makes error handling explicit and type-safe: + +```typescript +// Define error types +class NotFoundError { readonly _tag = "NotFoundError"; } +class NetworkError { readonly _tag = "NetworkError"; } + +// Function that can fail +const fetchUser = (id: string): Effect => ... + +// Handle specific errors +fetchUser("123").pipe( + Effect.catchTag("NotFoundError", () => Effect.succeed(defaultUser)), + Effect.catchTag("NetworkError", (err) => Effect.fail(new RetryableError(err))), +); + +// Or handle all errors +fetchUser("123").pipe( + Effect.catchAll((err) => { + console.error("Failed:", err); + return Effect.succeed(fallbackUser); + }), +); +``` + +The compiler ensures you handle (or propagate) every possible error. + +## Dependency Injection + +The `R` type parameter tracks what services your code needs: + +```typescript +// Define a service +class Database extends Context.Tag("Database") Effect; +}>() {} + +// Use it—Database appears in R +const getUsers: Effect = Effect.gen(function* () { + const db = yield* Database; + const rows = yield* db.query("SELECT * FROM users"); + return rows.map(toUser); +}); + +// Provide it when running +Effect.runPromise( + getUsers.pipe(Effect.provide(DatabaseLive)) // Satisfy the Database requirement +); +``` + +This makes testing trivial—swap `DatabaseLive` for `DatabaseTest` with no code changes. + +## Effect.gen: Where You'll See This Most + +In Effex, you'll mostly use `Effect.gen`: + +```typescript +const Counter = () => + Effect.gen(function* () { + const count = yield* Signal.make(0); + + return yield* $.div( + {}, + collect( + $.button({ onClick: () => count.update(n => n - 1) }, $.of("-")), + $.span({}, $.of(count)), + $.button({ onClick: () => count.update(n => n + 1) }, $.of("+")), + ), + ); + }); +``` + +Every `yield*` unwraps an Effect: +- `Signal.make(0)` returns an Effect that creates a Signal +- `$.div(...)` returns an Effect that creates a DOM element + +## The Three Type Parameters + +```typescript +Effect +``` + +- **`A`** (Success): What you get when it succeeds +- **`E`** (Error): What errors can occur (`never` = infallible) +- **`R`** (Requirements): What services it needs (`never` = none) + +In Effex: +- Elements are `Effect` +- Signals are created with `Effect, never, Scope>` +- Components return `Effect` + +## You Don't Need to Know More (Yet) + +This covers what you need for building Effex apps. Effex handles most Effect complexity for you—you'll rarely need to think about `Scope`, `Layer`, or advanced error handling. + +As you grow, explore: +- [Effect Documentation](https://effect.website/docs/introduction) +- [Why Effect?](https://effect.website/docs/why-effect) diff --git a/apps/docs/content/todo-app/00-introduction.md b/apps/docs/content/todo-app/00-introduction.md new file mode 100644 index 00000000..cf69367f --- /dev/null +++ b/apps/docs/content/todo-app/00-introduction.md @@ -0,0 +1,76 @@ +--- +title: "Build a Todo App with Effex" +description: "Learn Effex fundamentals by building a complete todo application from scratch" +order: 0 +--- + +# Build a Todo App with Effex + +In this tutorial, you'll build a fully-functional todo application while learning the core concepts of Effex. By the end, you'll understand: + +- How to create elements with the `$` factory +- Reactive state with Signals +- Building reusable components +- Handling user interactions +- Derived state and computed values +- Conditional rendering +- Working with lists + +## What You'll Build + +A todo app with the ability to: +- Add new todos +- Mark todos as complete +- Filter by status (all, active, completed) +- Clear completed todos +- Persist to localStorage + +## Prerequisites + +- Basic JavaScript/TypeScript knowledge +- Node.js 18+ installed +- A code editor (VS Code recommended) + +## A Quick Note on Effect + +Effex is built on [Effect](https://effect.website), a powerful TypeScript library for building robust applications. You don't need to understand Effect deeply to use Effex—we'll introduce concepts gradually as they become relevant. + +For now, just know that when you see `yield*`, think of it like `await`: + +```typescript +// async/await (familiar) +async function doSomething() { + const result = await fetchData(); + return result; +} + +// Effect.gen (same pattern) +Effect.gen(function* () { + const result = yield* fetchData(); + return result; +}); + +// Effect.gen (what you'll see most in Effex) +const MyComponent = () => + Effect.gen(function* () { + const count = yield* Signal.make(0); + return yield* $.div({}, $.of("Hello!")); + }); +``` + +The `yield*` is like `await`—it "unwraps" the result. The difference is that Effect tracks errors and dependencies in the type system, giving you compile-time safety that async/await can't provide. + +For a deeper introduction, see **[Effect in 2 Minutes](../effect-in-2-minutes.md)**. But you don't need to read it now—we'll explain concepts as they come up. Let's start building! + +## Chapters + +1. [Getting Started](./01-getting-started.md) - Set up your project +2. [Your First Element](./02-your-first-element.md) - Learn the `$` factory +3. [Making It Interactive](./03-making-it-interactive.md) - Add reactive state +4. [Building the Todo List](./04-building-the-todo-list.md) - Components and lists +5. [Toggling and Updating](./05-toggling-and-updating.md) - Handle interactions +6. [Adding New Todos](./06-adding-new-todos.md) - Form handling +7. [Derived State](./07-derived-state.md) - Computed values +8. [Conditional Rendering](./08-conditional-rendering.md) - Show/hide elements +9. [Deleting Todos](./09-deleting-todos.md) - Removing items +10. [Persistence](./10-persistence.md) - Save to localStorage diff --git a/apps/docs/content/todo-app/01-getting-started.md b/apps/docs/content/todo-app/01-getting-started.md new file mode 100644 index 00000000..380b2afa --- /dev/null +++ b/apps/docs/content/todo-app/01-getting-started.md @@ -0,0 +1,117 @@ +--- +title: "Chapter 1: Getting Started" +description: "Set up a new Effex project and understand the project structure" +order: 1 +--- + +# Chapter 1: Getting Started + +Let's create a new Effex project and get our development environment running. + +## Create Your Project + +Open your terminal and run: + +```bash +pnpm create effex@latest todo-app +``` + +When prompted, select: +- **Template**: SPA (Single Page Application) +- **Package manager**: pnpm (or your preference) + +Once complete, navigate into the project and start the dev server: + +```bash +cd todo-app +pnpm install +pnpm dev +``` + +Open [http://localhost:5173](http://localhost:5173) in your browser. You should see the default Effex welcome page with a counter. + +## Project Structure + +Your project looks like this: + +``` +todo-app/ +├── src/ +│ ├── main.ts # Application entry point +│ └── App.ts # Root component +├── index.html # HTML template +├── package.json +├── tsconfig.json +└── vite.config.ts +``` + +The key files: + +- **`src/main.ts`** - Bootstraps the app and mounts to the DOM +- **`src/App.ts`** - Your root component + +## Understanding main.ts + +Open `src/main.ts`. You'll see something like: + +```typescript +import { Effect } from "effect"; +import { $, mount, runApp } from "@effex/dom"; + +runApp( + Effect.gen(function* () { + const app = yield* App(); + yield* mount(app, container); + }), +); +``` + +Don't worry about understanding all of this yet. The key points: + +1. **`runApp`** starts the application +2. **`Effect.gen`** is like an async function (we use `yield*` instead of `await`) +3. **`mount`** attaches our app to the DOM + +## Simplify for Learning + +For this tutorial, we'll start simpler. Replace the contents of `src/main.ts` with: + +```typescript +import { Effect } from "effect"; +import { $, mount, runApp } from "@effex/dom"; + +// Get the root element +const container = document.getElementById("root"); +if (!container) throw new Error("Root element not found"); + +// Our app - just a simple div for now +const App = $.div({}, $.of("Hello, Effex!")); + +// Mount it +runApp( + Effect.gen(function* () { + yield* mount(App, container); + console.log("App mounted!"); + }), +); +``` + +Save the file. Your browser should now show "Hello, Effex!" + +## What Just Happened? + +Let's break it down: + +1. **`$.div({}, $.of("Hello, Effex!"))`** - Creates a div element with text content. The `$` object has methods for every HTML element (`$.div`, `$.span`, `$.button`, etc.). Text content is wrapped in `$.of()` + +2. **`mount(App, container)`** - Takes our element and renders it into the DOM container + +3. **`runApp(Effect.gen(...))`** - Runs our Effect, which mounts the app + +The `$` factory returns an Effect that, when run, creates a DOM element. Effects are lazy—they describe *what* to do, not *when* to do it. The `mount` function runs the Effect and attaches the result to the DOM. + +## Next Steps + +You've got a working Effex app! In the next chapter, we'll explore the `$` factory in depth and build out the structure of our todo app. + +[Next: Your First Element →](./02-your-first-element.md) diff --git a/apps/docs/content/todo-app/02-your-first-element.md b/apps/docs/content/todo-app/02-your-first-element.md new file mode 100644 index 00000000..07da66a5 --- /dev/null +++ b/apps/docs/content/todo-app/02-your-first-element.md @@ -0,0 +1,243 @@ +--- +title: "Chapter 2: Your First Element" +description: "Learn how to create and compose elements with the $ factory" +order: 2 +--- + +# Chapter 2: Your First Element + +The `$` factory is how you create HTML elements in Effex. Let's explore how it works and build the basic structure of our todo app. + +## The $ Factory + +The `$` object has a method for every HTML element: + +```typescript +$.div() //
+$.span() // +$.button() // +$.input() // +$.ul() //
    +$.li() //
  • +// ... and so on +``` + +## Adding Content + +Pass content as arguments: + +```typescript +// Text content +$.h1({}, $.of("My Todo App")) + +// Multiple children +$.div({}, + collect( + $.h1({}, $.of("My Todo App")), + $.p({}, $.of("A simple todo list")) + ) +) +``` + +## Adding Attributes + +Pass an attributes object as the first argument: + +```typescript +// With attributes +$.input({ type: "text", placeholder: "What needs to be done?" }) + +// Attributes + content +$.button({ class: "btn-primary" }, $.of("Add Todo")) + +// Attributes + children +$.div({ class: "container" }, + collect( + $.h1({}, $.of("My Todo App")), + $.p({}, $.of("A simple todo list")) + ) +) +``` + +Common attributes: +- **`class`** - CSS classes (string or array) +- **`id`** - Element ID +- **`style`** - Inline styles (object) +- **`onClick`**, **`onInput`**, etc. - Event handlers + +## Building Our Todo App Structure + +Let's create the basic HTML structure for our todo app. Update `src/main.ts`: + +```typescript +import { Effect } from "effect"; +import { $, collect, mount, runApp } from "@effex/dom"; + +const container = document.getElementById("root"); +if (!container) throw new Error("Root element not found"); + +const App = () => $.div({ class: "todo-app" }, + collect( + // Header + $.header({ class: "header" }, + collect( + $.h1({}, $.of("todos")), + $.input({ + class: "new-todo", + placeholder: "What needs to be done?", + autofocus: true, + }), + ), + ), + + // Main section (todo list) + $.main({ class: "main" }, + $.ul({ class: "todo-list" }, + collect( + // We'll make this dynamic later + $.li({ class: "todo-item" }, + collect( + $.input({ type: "checkbox", class: "toggle" }), + $.span({ class: "todo-text" }, $.of("Learn Effex")), + ), + ), + $.li({ class: "todo-item" }, + collect( + $.input({ type: "checkbox", class: "toggle" }), + $.span({ class: "todo-text" }, $.of("Build a todo app")), + ), + ), + ), + ), + ), + + // Footer + $.footer({ class: "footer" }, + $.span({ class: "todo-count" }, $.of("2 items left")), + ), + ), +); + +runApp( + Effect.gen(function* () { + yield* mount(App(), container); + }), +); +``` + +Save and check your browser. You should see a basic todo app structure! + +## Adding Some Styles + +Create a file `src/styles.css`: + +```css +.todo-app { + max-width: 500px; + margin: 40px auto; + font-family: system-ui, sans-serif; +} + +.header h1 { + font-size: 48px; + color: #b83f45; + text-align: center; + margin-bottom: 20px; +} + +.new-todo { + width: 100%; + padding: 16px; + font-size: 18px; + border: 1px solid #ccc; + border-radius: 4px; +} + +.todo-list { + list-style: none; + padding: 0; + margin: 20px 0; +} + +.todo-item { + display: flex; + align-items: center; + gap: 12px; + padding: 12px; + border-bottom: 1px solid #eee; +} + +.todo-text { + flex: 1; +} + +.toggle { + width: 20px; + height: 20px; +} + +.footer { + color: #777; + font-size: 14px; +} +``` + +Import it in your `src/main.ts`: + +```typescript +import "./styles.css"; +import { Effect } from "effect"; +// ... rest of imports +``` + +## Nesting and Composition + +Notice how elements nest naturally: + +```typescript +$.div({ class: "parent" }, + $.div({ class: "child" }, + $.span({}, $.of("Deeply nested content")) + ) +) +``` + +This creates: +```html +
    +
    + Deeply nested content +
    +
    +``` + +## Class Arrays + +The `class` attribute accepts arrays for cleaner conditional classes: + +```typescript +// These are equivalent: +$.div({ class: "btn btn-primary btn-large" }) +$.div({ class: ["btn", "btn-primary", "btn-large"] }) +``` + +This becomes useful when classes are conditional (we'll see this later with reactivity), or when using libraries like Tailwind. + +## What We've Built + +We now have: +- A header with a title and input field +- A main section with a hardcoded todo list +- A footer showing the count + +But it's all static! In the next chapter, we'll add reactivity with Signals to make our todos dynamic. + +## Key Takeaways + +1. **`$`** is a factory with methods for all HTML elements +2. Pass **attributes first**, then **content** +3. Use **`collect(...)`** to pass multiple children +4. Use **`$.of("text")`** for text content +5. Elements **nest naturally** - just put elements inside elements + +[← Previous: Getting Started](./01-getting-started.md) | [Next: Making It Interactive →](./03-making-it-interactive.md) diff --git a/apps/docs/content/todo-app/03-making-it-interactive.md b/apps/docs/content/todo-app/03-making-it-interactive.md new file mode 100644 index 00000000..1ce14ae8 --- /dev/null +++ b/apps/docs/content/todo-app/03-making-it-interactive.md @@ -0,0 +1,215 @@ +--- +title: "Chapter 3: Making It Interactive" +description: "Add reactivity to your app with Signals" +order: 3 +--- + +# Chapter 3: Making It Interactive + +Static HTML is nice, but we need our app to respond to user input. Enter **Signals**—Effex's reactive primitives. + +## What's a Signal? + +A Signal is a reactive container for a value. When the value changes, anything that depends on it automatically updates. + +```typescript +import { Signal } from "@effex/dom"; + +// Create a signal with initial value 0 +const count = yield* Signal.make(0); + +// Read the current value +const value = yield* count.get; // 0 + +// Set a new value +yield* count.set(5); + +// Update based on current value +yield* count.update(n => n + 1); // now 6 +``` + +Notice the `yield*` everywhere. Signals are Effects, so we need to "unwrap" them. This might feel verbose at first, but it gives us type safety and predictable behavior. + +## The Problem With Our Current App + +Right now, our app has hardcoded todos. We want to: +1. Store todos in a Signal +2. Have the UI automatically update when todos change + +## Converting to a Component + +To use Signals, we need to wrap our app in `Effect.gen`. This gives us a place to create and use reactive state. + +There are currently 4 types of Signals in Effex: +- `Signal`: Holds a single value of type T +- `Signal.Array`: Holds an array of type T with array-specific methods +- `Signal.Set`: Holds a set of type T with set-specific methods +- `Signal.Map`: Holds a map of keys K to values with map-specific methods + +Each of them will update the UI automatically when changed. A key thing to note is that the updates are surgical. Only the parts of the DOM that depend on the changed value will update, not the whole component. `Effect.gen` runs only once at mount time. + +Update `src/main.ts`: + +```typescript +import "./styles.css"; +import { Effect } from "effect"; +import { $, collect, mount, Readable, runApp, Signal, t } from "@effex/dom"; + +const container = document.getElementById("root"); +if (!container) throw new Error("Root element not found"); + +// Define our Todo type +interface Todo { + id: number; + text: string; + completed: boolean; +} + +// Our app as a Component +const App = () => + Effect.gen(function* () { + // Create a signal to hold our todos + const todos = yield* Signal.Array.make([ + { id: 1, text: "Learn Effex", completed: false }, + { id: 2, text: "Build a todo app", completed: false }, + ]); + + const completedTodoCount = Readable.map(todos, t => t.filter(todo => todo.completed).length); + const remainingTodoCount = Readable.map(todos, t => t.filter(todo => !todo.completed).length); + + return yield* $.div({ class: "todo-app" }, + collect( + $.header({ class: "header" }, + collect( + $.h1({}, $.of("todos")), + $.input({ + class: "new-todo", + placeholder: "What needs to be done?", + autofocus: true, + }), + ), + ), + + $.main({ class: "main" }, + $.ul({ class: "todo-list" }, + // Still hardcoded for now - we'll fix this next chapter + $.li({ class: "todo-item" }, + collect( + $.input({ type: "checkbox", class: "toggle" }), + $.span({ class: "todo-text" }, $.of("Learn Effex")), + ), + ), + ), + ), + + $.footer({ class: "footer" }, + collect( + // Make the count reactive! + // Note: use the `t` template literal for concatenating strings + // with reactive values + $.span({ class: "todo-count" }, $.of(t`${completedTodoCount} items completed`)), + $.span({ class: "todo-count" }, $.of(t`${remainingTodoCount} items left`)), + ), + ), + ), + ); + }); + +runApp( + Effect.gen(function* () { + yield* mount(yield* App(), container); + }), +); +``` + +## What Changed? + +1. **`Effect.gen(function* () { ... })`** - Wraps our app in a component. The `function*` makes it a generator, letting us use `yield*`. + +2. **`Signal.Array.make([...])`** - Creates a Signal holding an array of todos + +3. **`Readable.map(todos, t => ...)`** - Derives a new value from the signal. When `todos` changes, this automatically updates. + +## Reactive Text + +Look at these lines: +```typescript +const completedTodoCount = Readable.map(todos, t => t.filter(todo => todo.completed).length); +const remainingTodoCount = Readable.map(todos, t => t.filter(todo => !todo.completed).length); +``` + +`Readable.map()` creates a **derived value** that updates whenever `todos` changes. The span's text content is now reactive! + +## Adding Interactivity + +Let's add a button to test that reactivity works. Add this after the footer: + +```typescript +$.button( + { + class: "test-btn", + onClick: () => todos.push({ + id: Date.now(), + text: "New todo!", + completed: false + }) + }, + $.of("Add Test Todo") +) +``` + +Add some CSS for the button: +```css +.test-btn { + margin-top: 20px; + padding: 8px 16px; + cursor: pointer; +} +``` + +Now click the button! Watch the "items left" count increase automatically. + +## How Event Handlers Work + +Event handlers return Effects. Signal methods like `todos.push(...)` and `todos.update(...)` already return Effects, so simple handlers just work: + +```typescript +onClick: () => todos.update(t => [...t, newTodo]) +``` + +For more complex handlers, use `Effect.gen`: + +```typescript +onClick: () => Effect.gen(function* () { + console.log("Before update"); + yield* todos.update(t => [...t, newTodo]); + console.log("After update"); +}) +``` + +When a handler has a no-op branch, return `Effect.void`. + +## Understanding Reactivity + +Here's what happens when you click the button: + +1. `onClick` fires, calling `todos.update(...)` +2. The `todos` Signal's value changes +3. Everything derived from `todos` (like our `Readable.map()`) recomputes +4. The DOM updates automatically—only the text nodes change + +This is **fine-grained reactivity**. We don't re-render the whole app. Only the specific text that depends on `todos` updates. + +## Key Takeaways + +1. **Signals** hold reactive values +2. Use **`Signal.make(initialValue)`** to create them +3. **`Readable.map(signal, fn)`** derives new values that update automatically +4. **Components** (`Effect.gen`) give you a place to create and manage state +5. Reactivity is **fine-grained**—only what changed updates + +## Cleanup + +Remove the test button before the next chapter. We'll add proper todo creation soon. + +[← Previous: Your First Element](./02-your-first-element.md) | [Next: Building the Todo List →](./04-building-the-todo-list.md) diff --git a/apps/docs/content/todo-app/04-building-the-todo-list.md b/apps/docs/content/todo-app/04-building-the-todo-list.md new file mode 100644 index 00000000..2770e494 --- /dev/null +++ b/apps/docs/content/todo-app/04-building-the-todo-list.md @@ -0,0 +1,245 @@ +--- +title: "Chapter 4: Building the Todo List" +description: "Render lists reactively with each and create reusable components" +order: 4 +--- + +# Chapter 4: Building the Todo List + +Our todos are stored in a Signal, but we're still rendering hardcoded items. Let's make the list dynamic using the `each` helper and create a reusable `TodoItem` component. + +## The each Helper + +To render a list reactively, we use `each`: + +```typescript +import { each } from "@effex/dom"; + +each(itemsSignal, { + key: (item) => item.id, // Unique identifier + render: (item) => $.li({}, $.of(Readable.map(item, i => i.text))) +}) +``` + +The `each` helper: +- Takes a Signal containing an array +- Renders each item using the `render` function +- Uses `key` to track items (like React's `key` prop) +- Automatically adds/removes/reorders DOM elements when the array changes + +## Creating a TodoItem Component + +First, let's create a component for individual todo items. Create a new file `src/components/TodoItem.ts`: + +```typescript +import { $, collect, Readable } from "@effex/dom"; + +interface Todo { + id: number; + text: string; + completed: boolean; +} + +interface TodoItemProps { + todo: Readable; +} + +export const TodoItem = (props: TodoItemProps) => + $.li({ class: "todo-item" }, + collect( + $.input({ + type: "checkbox", + class: "toggle", + checked: Readable.map(props.todo, t => t.completed), + }), + $.span({ class: "todo-text" }, $.of(Readable.map(props.todo, t => t.text))), + ), + ); +``` + +A few things to note: + +1. **Plain function** - Components are just functions that return elements or `Effect.gen` +2. **`todo: Readable`** - The todo is passed as a `Readable`, not a plain value. This lets us derive reactive values from it. +3. **`Readable.map(props.todo, t => t.text)`** - We extract the text reactively. If the todo updates, the text updates. + +## Using TodoItem with each + +Now update `src/main.ts`: + +```typescript +import "./styles.css"; +import { Effect } from "effect"; +import { $, collect, each, mount, Readable, runApp, Signal, t } from "@effex/dom"; +import { TodoItem } from "./components/TodoItem"; + +const container = document.getElementById("root"); +if (!container) throw new Error("Root element not found"); + +interface Todo { + id: number; + text: string; + completed: boolean; +} + +const App = () => + Effect.gen(function* () { + const todos = yield* Signal.Array.make([ + { id: 1, text: "Learn Effex", completed: false }, + { id: 2, text: "Build a todo app", completed: false }, + { id: 3, text: "Ship it!", completed: false }, + ]); + + const completedTodoCount = Readable.map(todos, t => t.filter(todo => todo.completed).length); + const remainingTodoCount = Readable.map(todos, t => t.filter(todo => !todo.completed).length); + + return yield* $.div({ class: "todo-app" }, + collect( + $.header({ class: "header" }, + collect( + $.h1({}, $.of("todos")), + $.input({ + class: "new-todo", + placeholder: "What needs to be done?", + autofocus: true, + }), + ), + ), + + $.main({ class: "main" }, + each(todos, { + key: (todo) => todo.id, + container: () => $.ul({ class: "todo-list" }), // $.div() by default + render: (todo) => TodoItem({ todo }), + }), + ), + + $.footer({ class: "footer" }, + collect( + $.span({ class: "todo-count" }, $.of(t`${completedTodoCount} items completed`)), + $.span({ class: "todo-count" }, $.of(t`${remainingTodoCount} items left`)), + ), + ), + ), + ); + }); + +runApp(mount(App(), container)); +``` + +The key change is: + +```typescript +each(todos, { + key: (todo) => todo.id, + container: () => $.ul({ class: "todo-list" }), + render: (todo) => TodoItem({ todo }), +}) +``` + +This renders a `TodoItem` for each todo in the array. The `key` function extracts the unique ID for efficient updates. + +## How each Works + +When the `todos` Signal changes: + +1. **Added items** - New `TodoItem` components are created and inserted +2. **Removed items** - Old `TodoItem` components are removed from the DOM +3. **Reordered items** - Elements are moved efficiently (not recreated) +4. **Updated items** - The individual `todo` Readable updates, and only affected text/attributes change + +This is fine-grained: changing one todo's text doesn't recreate the entire list. + +## Testing It Out + +Let's add a test button again to see the list update: + +```typescript +$.button( + { + onClick: () => todos.push({ + id: Date.now(), + text: "New todo!", + completed: false + }) + }, + $.of("Add Todo"), +), +$.button( + { + onClick: () => todos.pop().pipe(Effect.ignore) + }, + $.of("Remove Last"), +) +``` + +Click "Add Todo" and watch items appear. Click "Remove Last" and watch them disappear. The list updates smoothly! + +## Component Props Pattern + +Notice how we pass props to `TodoItem`: + +```typescript +TodoItem({ todo }) +``` + +And define the component as a plain function: + +```typescript +const TodoItem = (props: TodoItemProps) => + $.li( + // ... use props.todo + ); +``` + +This is a clean pattern for creating reusable components. The props object gives you type safety and clear interfaces. + +## Readables in Props + +The `todo` prop is a `Readable`, not a plain `Todo`. This is important: + +```typescript +// ❌ Plain value - won't update when todo changes +interface TodoItemProps { + todo: Todo; +} + +// ✅ Readable - updates automatically +interface TodoItemProps { + todo: Readable; +} +``` + +When `each` renders an item, it provides a `Readable` that tracks that specific item. When you use `Readable.map()` on it, you get a derived value that updates when the item changes. + +Additionally, sometimes you may want a prop to be optionally reactive. You can define it as: + +```typescript +interface MyComponentProps { + title: Readable.Reactive; +} +``` + +And in your component, you can normalize it to be reactive with: + +```typescript +const MyComponent = (props: MyComponentProps) => + Effect.gen(function* () { + const reactiveTitle = Readable.normalize(props.title); + // Now use Readable.map(reactiveTitle, ...) as needed + }); +``` + +## Key Takeaways + +1. **`each`** renders arrays reactively +2. Always provide a **`key`** function for efficient updates +3. The `render` function receives a **`Readable`** for each item +4. **Components** are plain functions that accept a props object +5. Use **`Readable.map()`** to derive values from Readable props + +## Cleanup + +Remove the test buttons before the next chapter. + +[← Previous: Making It Interactive](./03-making-it-interactive.md) | [Next: Toggling and Updating →](./05-toggling-and-updating.md) diff --git a/apps/docs/content/todo-app/05-toggling-and-updating.md b/apps/docs/content/todo-app/05-toggling-and-updating.md new file mode 100644 index 00000000..04559560 --- /dev/null +++ b/apps/docs/content/todo-app/05-toggling-and-updating.md @@ -0,0 +1,219 @@ +--- +title: "Chapter 5: Toggling and Updating" +description: "Handle user interactions to toggle todo completion status" +order: 5 +--- + +# Chapter 5: Toggling and Updating + +Our todo list displays items, but clicking the checkbox doesn't do anything. Let's make it interactive by handling the toggle action. + +## The Challenge + +We need to: +1. Detect when a checkbox is clicked +2. Find the corresponding todo in our array +3. Toggle its `completed` status +4. Update the UI (automatically, thanks to reactivity!) + +## Passing a Toggle Handler + +Since the `todos` Signal lives in `App`, we need to pass a toggle function down to `TodoItem`. + +First, update the props interface and component in `src/components/TodoItem.ts`: + +```typescript +import { Effect } from "effect"; +import { $, collect, Readable } from "@effex/dom"; + +interface Todo { + id: number; + text: string; + completed: boolean; +} + +interface TodoItemProps { + todo: Readable; + onToggle: (id: number) => Effect.Effect; +} + +export const TodoItem = (props: TodoItemProps) => + Effect.gen(function* () { + const todoId = yield* Readable.map(props.todo, t => t.id).get; + + return yield* $.li( + { + class: Readable.map(props.todo, t => + t.completed ? "todo-item completed" : "todo-item" + ), + }, + collect( + $.input({ + type: "checkbox", + class: "toggle", + checked: Readable.map(props.todo, t => t.completed), + onChange: () => props.onToggle(todoId), + }), + $.span({ class: "todo-text" }, $.of(Readable.map(props.todo, t => t.text))), + ), + ); + }); +``` + +What changed: + +1. **`onToggle: (id: number) => Effect.Effect`** - Added to props, returns an Effect +2. **`todoId`** - We read the ID once at mount time (IDs are stable) +3. **`onChange`** - Calls `onToggle(todoId)` which returns an Effect +4. **Conditional class** - `Readable.map(props.todo, t => t.completed ? "completed" : "")` adds a class when completed + +## Implementing the Toggle in App + +Now update `src/main.ts` to create the toggle function and pass it down: + +```typescript +import "./styles.css"; +import { Effect } from "effect"; +import { $, collect, each, mount, Readable, runApp, Signal } from "@effex/dom"; +import { TodoItem } from "./components/TodoItem"; + +const container = document.getElementById("root"); +if (!container) throw new Error("Root element not found"); + +interface Todo { + id: number; + text: string; + completed: boolean; +} + +const App = () => + Effect.gen(function* () { + const todos = yield* Signal.Array.make([ + { id: 1, text: "Learn Effex", completed: false }, + { id: 2, text: "Build a todo app", completed: false }, + { id: 3, text: "Ship it!", completed: false }, + ]); + + // Toggle a todo's completed status + const toggleTodo = (id: number) => + todos.update(items => + items.map(todo => + todo.id === id + ? { ...todo, completed: !todo.completed } + : todo + ) + ); + + return yield* $.div({ class: "todo-app" }, + collect( + $.header({ class: "header" }, + collect( + $.h1({}, $.of("todos")), + $.input({ + class: "new-todo", + placeholder: "What needs to be done?", + autofocus: true, + }), + ), + ), + + $.main({ class: "main" }, + $.ul( + { class: "todo-list" }, + each(todos, { + key: (todo) => todo.id, + render: (todo) => TodoItem({ todo, onToggle: toggleTodo }), + }), + ), + ), + + $.footer({ class: "footer" }, + $.span( + { class: "todo-count" }, + $.of(Readable.map(todos, t => { + const remaining = t.filter(todo => !todo.completed).length; + return `${remaining} item${remaining === 1 ? "" : "s"} left`; + })), + ), + ), + ), + ); + }); + +runApp(mount(App(), container)); +``` + +The key addition is `toggleTodo`: + +```typescript +const toggleTodo = (id: number) => + todos.update(items => + items.map(todo => + todo.id === id + ? { ...todo, completed: !todo.completed } + : todo + ) + ); +``` + +This: +1. Takes a todo ID +2. Maps over all todos +3. Finds the one with matching ID and toggles its `completed` property +4. Returns an Effect that updates the Signal + +## Add Completed Styling + +Update your CSS to show completed todos differently: + +```css +.todo-item.completed .todo-text { + text-decoration: line-through; + color: #aaa; +} +``` + +Now click a checkbox! The todo should: +- Get checked +- Show a strikethrough +- Add the "completed" class + +## Understanding the Flow + +Here's what happens when you click a checkbox: + +1. **`onChange`** fires → calls `onToggle(todoId)` +2. **`toggleTodo`** runs → returns `todos.update(...)` Effect +3. **`todos` Signal** updates with new array +4. **`each`** detects the change +5. The specific **`TodoItem`'s `todo` Readable** updates +6. **`Readable.map()` derivations** recompute (`completed`, `class`) +7. **DOM updates** - only the checkbox and span change + +No re-render of the whole list. No diffing algorithm. Just precise, surgical updates. + +## Updating the Count + +Our footer still shows total items. Let's fix it to show only incomplete items: + +```typescript +$.span( + { class: "todo-count" }, + $.of(Readable.map(todos, t => { + const remaining = t.filter(todo => !todo.completed).length; + return `${remaining} item${remaining === 1 ? "" : "s"} left`; + })), +), +``` + +Now the count updates as you toggle todos! + +## Key Takeaways + +1. **Pass callbacks down** to child components for updates +2. **`signal.update()`** returns an Effect that modifies state immutably +3. **Conditional classes** work with `Readable.map()` returning different strings +4. **Event handlers** return Effects +5. Updates are **fine-grained**—only changed parts of the DOM update + +[← Previous: Building the Todo List](./04-building-the-todo-list.md) | [Next: Adding New Todos →](./06-adding-new-todos.md) diff --git a/apps/docs/content/todo-app/06-adding-new-todos.md b/apps/docs/content/todo-app/06-adding-new-todos.md new file mode 100644 index 00000000..e2541b89 --- /dev/null +++ b/apps/docs/content/todo-app/06-adding-new-todos.md @@ -0,0 +1,232 @@ +--- +title: "Chapter 6: Adding New Todos" +description: "Handle form input to add new todos to the list" +order: 6 +--- + +# Chapter 6: Adding New Todos + +Time to make that input field work! We'll capture user input and add new todos to our list. + +## The Plan + +1. Track the input field's value in a Signal +2. Listen for Enter key press +3. Add a new todo to the list +4. Clear the input field + +## Adding Input State + +Update `src/main.ts` to add a Signal for the input value: + +```typescript +const App = () => + Effect.gen(function* () { + const todos = yield* Signal.Array.make([ + { id: 1, text: "Learn Effex", completed: false }, + { id: 2, text: "Build a todo app", completed: false }, + { id: 3, text: "Ship it!", completed: false }, + ]); + + // Track the input field value + const newTodoText = yield* Signal.make(""); + + const toggleTodo = (id: number) => + todos.update(items => + items.map(todo => + todo.id === id + ? { ...todo, completed: !todo.completed } + : todo + ) + ); + + // Add a new todo + const addTodo = () => + Effect.gen(function* () { + const text = yield* newTodoText.get; + if (text.trim()) { + yield* todos.push({ id: Date.now(), text: text.trim(), completed: false }); + yield* newTodoText.set(""); + } + }); + + return yield* $.div({ class: "todo-app" }, + collect( + $.header({ class: "header" }, + collect( + $.h1({}, $.of("todos")), + $.input({ + class: "new-todo", + placeholder: "What needs to be done?", + autofocus: true, + value: newTodoText, + onInput: (e) => newTodoText.set((e.target as HTMLInputElement).value), + onKeyDown: (e) => { + if (e.key === "Enter") return addTodo(); + return Effect.void; + }, + }), + ), + ), + + // ... rest of the app + ), + ); + }); +``` + +## Breaking It Down + +### Two-Way Binding + +```typescript +$.input({ + value: newTodoText, + onInput: (e) => newTodoText.set((e.target as HTMLInputElement).value), +}) +``` + +- **`value: newTodoText`** - Binds the input's value to the Signal (display) +- **`onInput`** - Returns an Effect that updates the Signal when the user types (input) + +This creates two-way binding: the Signal controls the input, and the input updates the Signal. + +### Effect-Based Handlers + +The `addTodo` function uses `Effect.gen` to read and write Signals: + +```typescript +const addTodo = () => + Effect.gen(function* () { + const text = yield* newTodoText.get; // Read value via Effect + if (text.trim()) { + yield* todos.update(items => [ // Update via Effect + ...items, + { id: Date.now(), text: text.trim(), completed: false } + ]); + yield* newTodoText.set(""); // Set via Effect + } + }); +``` + +Signal operations like `.get`, `.set()`, and `.update()` all return Effects. Use `yield*` inside `Effect.gen` to execute them. + +### Handling Enter Key + +```typescript +onKeyDown: (e) => { + if (e.key === "Enter") return addTodo(); + return Effect.void; +} +``` + +We check for the Enter key and return the `addTodo()` Effect. For other keys, we return `Effect.void` (a no-op Effect). + +## The Complete App So Far + +Here's the full `src/main.ts`: + +```typescript +import "./styles.css"; +import { Effect } from "effect"; +import { $, collect, each, mount, Readable, runApp, Signal } from "@effex/dom"; +import { TodoItem } from "./components/TodoItem"; + +const container = document.getElementById("root"); +if (!container) throw new Error("Root element not found"); + +interface Todo { + id: number; + text: string; + completed: boolean; +} + +const App = () => + Effect.gen(function* () { + const todos = yield* Signal.Array.make([ + { id: 1, text: "Learn Effex", completed: false }, + { id: 2, text: "Build a todo app", completed: false }, + { id: 3, text: "Ship it!", completed: false }, + ]); + + const newTodoText = yield* Signal.make(""); + + const toggleTodo = (id: number) => + todos.update(items => + items.map(todo => + todo.id === id + ? { ...todo, completed: !todo.completed } + : todo + ) + ); + + const addTodo = () => + Effect.gen(function* () { + const text = yield* newTodoText.get; + if (text.trim()) { + yield* todos.push({ id: Date.now(), text: text.trim(), completed: false }); + yield* newTodoText.set(""); + } + }); + + return yield* $.div({ class: "todo-app" }, + collect( + $.header({ class: "header" }, + collect( + $.h1({}, $.of("todos")), + $.input({ + class: "new-todo", + placeholder: "What needs to be done?", + autofocus: true, + value: newTodoText, + onInput: (e) => newTodoText.set((e.target as HTMLInputElement).value), + onKeyDown: (e) => { + if (e.key === "Enter") return addTodo(); + return Effect.void; + }, + }), + ), + ), + + $.main({ class: "main" }, + $.ul( + { class: "todo-list" }, + each(todos, { + key: (todo) => todo.id, + render: (todo) => TodoItem({ todo, onToggle: toggleTodo }), + }), + ), + ), + + $.footer({ class: "footer" }, + $.span( + { class: "todo-count" }, + $.of(Readable.map(todos, t => { + const remaining = t.filter(todo => !todo.completed).length; + return `${remaining} item${remaining === 1 ? "" : "s"} left`; + })), + ), + ), + ), + ); + }); + +runApp(mount(yield* App(), container)); +``` + +## Try It Out + +1. Type in the input field +2. Press Enter +3. Watch the new todo appear! +4. The input clears automatically +5. The count updates + +## Key Takeaways + +1. **Two-way binding** with `value` + `onInput` returning an Effect +2. **Signal operations** (`.get`, `.set()`, `.update()`) return Effects — use `yield*` in `Effect.gen` +3. **Event handlers** return Effects (use `Effect.void` for no-ops) +4. New items appear **automatically** thanks to `each` and reactivity + +[← Previous: Toggling and Updating](./05-toggling-and-updating.md) | [Next: Derived State →](./07-derived-state.md) diff --git a/apps/docs/content/todo-app/07-derived-state.md b/apps/docs/content/todo-app/07-derived-state.md new file mode 100644 index 00000000..95a4c871 --- /dev/null +++ b/apps/docs/content/todo-app/07-derived-state.md @@ -0,0 +1,239 @@ +--- +title: "Chapter 7: Derived State" +description: "Compute values from signals with Readable.map and Readable.zipWith" +order: 7 +--- + +# Chapter 7: Derived State + +We've been using `Readable.map` to transform signal values. Now let's go deeper into derived state and add filtering to our todo app. + +## Readable.map + +`Readable.map` creates a derived value from a signal: + +```typescript +const count = yield* Signal.make(5); + +// Derived value - updates when count changes +const doubled = Readable.map(count, n => n * 2); + +// Use it in the UI +$.span({}, $.of(doubled)) // Displays "10", updates automatically +``` + +The derived value: +- Updates automatically when the source changes +- Is read-only (you can't `.set()` it directly) +- Is lazy - only computes when actually used + +## Adding a Filter + +Let's add filter buttons: All, Active, Completed. First, add a filter Signal: + +```typescript +type Filter = "all" | "active" | "completed"; + +const App = () => + Effect.gen(function* () { + const todos = yield* Signal.make([...]); + const newTodoText = yield* Signal.make(""); + const filter = yield* Signal.make("all"); + + // ... + }); +``` + +## Computing Filtered Todos + +Now we need a derived value that filters todos based on the current filter. We could use `Readable.map`, but we need data from *two* signals (`todos` and `filter`). + +### Using Readable.zipWith + +For combining multiple signals, use `Readable.zipWith`: + +```typescript +import { $, collect, each, Readable, Signal, when } from "@effex/dom"; + +// Inside App component: +const filteredTodos = Readable.zipWith(todos, filter, (todoList, currentFilter) => { + switch (currentFilter) { + case "active": + return todoList.filter(t => !t.completed); + case "completed": + return todoList.filter(t => t.completed); + default: + return todoList; + } +}); +``` + +`Readable.zipWith` takes: +1. Two source signals/readables +2. A function that receives both values and computes the result + +When *either* source changes, the derived value recomputes. + +## Rendering the Filtered List + +Replace the `each(todos, ...)` with `each(filteredTodos, ...)`: + +```typescript +each(filteredTodos, { + container: () => $.ul({ class: "todo-list" }), + key: (todo) => todo.id, + render: (todo) => TodoItem({ todo, onToggle: toggleTodo }), +}) +``` + +## Adding Filter Buttons + +Add the filter buttons in the footer: + +```typescript +$.footer({ class: "footer" }, + collect( + $.span( + { class: "todo-count" }, + $.of(Readable.map(todos, t => { + const remaining = t.filter(todo => !todo.completed).length; + return `${remaining} item${remaining === 1 ? "" : "s"} left`; + })) + ), + + $.div({ class: "filters" }, + collect( + $.button( + { + class: Readable.map(filter, f => f === "all" ? "filter-btn selected" : "filter-btn"), + onClick: () => filter.set("all"), + }, + $.of("All") + ), + $.button( + { + class: Readable.map(filter, f => f === "active" ? "filter-btn selected" : "filter-btn"), + onClick: () => filter.set("active"), + }, + $.of("Active") + ), + $.button( + { + class: Readable.map(filter, f => f === "completed" ? "filter-btn selected" : "filter-btn"), + onClick: () => filter.set("completed"), + }, + $.of("Completed") + ), + ) + ), + ) +), +``` + +Each button: +- Has a reactive class that highlights when selected +- Sets the filter on click + +## Add Filter Styles + +```css +.filters { + display: flex; + gap: 8px; + margin-top: 12px; +} + +.filter-btn { + padding: 4px 8px; + border: 1px solid transparent; + background: none; + cursor: pointer; +} + +.filter-btn:hover { + border-color: #ddd; +} + +.filter-btn.selected { + border-color: #b83f45; +} +``` + +## The Complete Footer + +Here's the full footer section: + +```typescript +$.footer({ class: "footer" }, + collect( + $.span( + { class: "todo-count" }, + $.of(Readable.map(todos, t => { + const remaining = t.filter(todo => !todo.completed).length; + return `${remaining} item${remaining === 1 ? "" : "s"} left`; + })) + ), + + $.div({ class: "filters" }, + collect( + $.button( + { + class: Readable.map(filter, f => f === "all" ? "filter-btn selected" : "filter-btn"), + onClick: () => filter.set("all"), + }, + $.of("All") + ), + $.button( + { + class: Readable.map(filter, f => f === "active" ? "filter-btn selected" : "filter-btn"), + onClick: () => filter.set("active"), + }, + $.of("Active") + ), + $.button( + { + class: Readable.map(filter, f => f === "completed" ? "filter-btn selected" : "filter-btn"), + onClick: () => filter.set("completed"), + }, + $.of("Completed") + ), + ) + ), + ) +), +``` + +## Try It Out + +1. Add some todos +2. Complete a few +3. Click the filter buttons +4. Watch the list update instantly! + +## When to Use What + +| Situation | Use | +|-----------|-----| +| Transform one signal | `Readable.map(signal, fn)` | +| Combine two signals | `Readable.zipWith(s1, s2, fn)` | +| Combine multiple signals | `Readable.zip(s1, s2, s3, ...)` | +| Just need the value once | `yield* signal.get` | + +## Derived vs Computed in Other Frameworks + +If you've used other frameworks: +- **Vue**: `Readable.zipWith` is like `computed()` +- **Solid**: It's like `createMemo()` +- **Svelte**: It's like `$:` reactive statements + +The key difference: Effex derivations are explicit and type-safe. You always know what depends on what. + +## Key Takeaways + +1. **`Readable.map`** transforms a single signal +2. **`Readable.zipWith`** combines two signals +3. Derived values **update automatically** when sources change +4. Derived values are **read-only** +5. Use derived state for **computed/filtered views** of your data + +[← Previous: Adding New Todos](./06-adding-new-todos.md) | [Next: Conditional Rendering →](./08-conditional-rendering.md) diff --git a/apps/docs/content/todo-app/08-conditional-rendering.md b/apps/docs/content/todo-app/08-conditional-rendering.md new file mode 100644 index 00000000..7b4053c3 --- /dev/null +++ b/apps/docs/content/todo-app/08-conditional-rendering.md @@ -0,0 +1,249 @@ +--- +title: "Chapter 8: Conditional Rendering" +description: "Show and hide elements with when and add a 'Clear Completed' button" +order: 8 +--- + +# Chapter 8: Conditional Rendering + +Sometimes you need to show or hide parts of your UI based on state. Let's add a "Clear Completed" button that only appears when there are completed todos. + +## The when Helper + +The `when` helper conditionally renders elements: + +```typescript +import { when } from "@effex/dom"; + +when(condition, { + onTrue: () => $.div({}, $.of("Shown when true")), + onFalse: () => $.div({}, $.of("Shown when false")), // optional +}) +``` + +- **`condition`** - A `Readable` or boolean +- **`onTrue`** - Rendered when condition is true +- **`onFalse`** - Rendered when condition is false (optional) + +When the condition changes, elements are added/removed from the DOM automatically. + +## Clear Completed Button + +Let's add a button that: +1. Only shows when there are completed todos +2. Removes all completed todos when clicked + +First, create a derived value for whether there are any completed todos: + +```typescript +const hasCompletedTodos = Readable.map(todos, t => t.some(todo => todo.completed)); +``` + +Then add the clear function: + +```typescript +const clearCompleted = () => + todos.update(items => items.filter(t => !t.completed)); +``` + +Now use `when` in the footer: + +```typescript +import { $, collect, each, Readable, Signal, when } from "@effex/dom"; + +// In the footer: +$.footer({ class: "footer" }, + collect( + $.span( + { class: "todo-count" }, + $.of(Readable.map(todos, t => { + const remaining = t.filter(todo => !todo.completed).length; + return `${remaining} item${remaining === 1 ? "" : "s"} left`; + })) + ), + + $.div({ class: "filters" }, + collect( + // ... filter buttons + ) + ), + + // Clear completed button - only shows when there are completed todos + when(hasCompletedTodos, { + onTrue: () => $.button( + { + class: "clear-completed", + onClick: () => clearCompleted(), + }, + $.of("Clear completed") + ), + }), + ) +), +``` + +## Add Styling + +```css +.clear-completed { + float: right; + background: none; + border: none; + cursor: pointer; + color: #777; +} + +.clear-completed:hover { + text-decoration: underline; +} +``` + +## Empty State + +Let's also show a message when there are no todos at all: + +```typescript +const hasTodos = Readable.map(todos, t => t.length > 0); + +// Wrap the main section +when(hasTodos, { + onTrue: () => $.main({ class: "main" }, + each(filteredTodos, { + container: () => $.ul({ class: "todo-list" }), + key: (todo) => todo.id, + render: (todo) => TodoItem({ todo, onToggle: toggleTodo }), + }) + ), + ), + onFalse: () => $.p({ class: "empty-state" }, $.of("No todos yet. Add one above!")), +}), +``` + +Add the empty state styling: + +```css +.empty-state { + text-align: center; + color: #999; + padding: 20px; +} +``` + +## Hiding the Footer When Empty + +The footer should probably also hide when there are no todos: + +```typescript +when(hasTodos, { + onTrue: () => $.footer({ class: "footer" }, + collect( + // ... footer content + ) + ), +}), +``` + +## The Complete App Structure + +Here's how the main structure looks now: + +```typescript +return yield* $.div({ class: "todo-app" }, + collect( + // Header (always shown) + $.header({ class: "header" }, + collect( + $.h1({}, $.of("todos")), + $.input({ + class: "new-todo", + placeholder: "What needs to be done?", + autofocus: true, + value: newTodoText, + onInput: (e) => newTodoText.set((e.target as HTMLInputElement).value), + onKeyDown: (e) => { + if (e.key === "Enter") return addTodo(); + return Effect.void; + }, + }), + ) + ), + + // Main section (only when todos exist) + when(hasTodos, { + onTrue: () => $.main({ class: "main" }, + each(filteredTodos, { + container: () => $.ul({ class: "todo-list" }), + key: (todo) => todo.id, + render: (todo) => TodoItem({ todo, onToggle: toggleTodo }), + }) + ), + ), + onFalse: () => $.p({ class: "empty-state" }, $.of("No todos yet. Add one above!")), + }), + + // Footer (only when todos exist) + when(hasTodos, { + onTrue: () => $.footer({ class: "footer" }, + collect( + $.span( + { class: "todo-count" }, + $.of(Readable.map(todos, t => { + const remaining = t.filter(todo => !todo.completed).length; + return `${remaining} item${remaining === 1 ? "" : "s"} left`; + })) + ), + $.div({ class: "filters" }, + collect( + // Filter buttons... + ) + ), + when(hasCompletedTodos, { + onTrue: () => $.button( + { class: "clear-completed", onClick: () => clearCompleted() }, + $.of("Clear completed") + ), + }), + ) + ), + }), + ) +); +``` + +## when vs Conditional Classes + +You might wonder when to use `when` vs just toggling CSS: + +| Use `when` | Use CSS/classes | +|------------|-----------------| +| Element shouldn't exist in DOM | Element should exist but be hidden | +| Has setup/cleanup logic | Simple show/hide | +| Saves memory when hidden | Needs to preserve state | + +For our "Clear Completed" button, `when` makes sense—there's no reason to have an invisible button in the DOM. + +## Animations with when + +You can add enter/exit animations to `when`: + +```typescript +when(condition, { + onTrue: () => $.div({}, $.of("Animated!")), + animate: { + enter: "fade-in", + exit: "fade-out", + }, +}) +``` + +We won't cover animations in depth here, but know that Effex supports CSS-based animations for conditional elements. + +## Key Takeaways + +1. **`when`** conditionally renders elements +2. Pass a **`Readable`** as the condition +3. **`onTrue`** renders when true, **`onFalse`** when false +4. Elements are **added/removed from DOM**, not just hidden +5. Use `when` for **presence**, CSS for **visibility** + +[← Previous: Derived State](./07-derived-state.md) | [Next: Deleting Todos →](./09-deleting-todos.md) diff --git a/apps/docs/content/todo-app/09-deleting-todos.md b/apps/docs/content/todo-app/09-deleting-todos.md new file mode 100644 index 00000000..6a0eed5d --- /dev/null +++ b/apps/docs/content/todo-app/09-deleting-todos.md @@ -0,0 +1,153 @@ +--- +title: "Chapter 9: Deleting Todos" +description: "Add delete buttons to remove individual todos" +order: 9 +--- + +# Chapter 9: Deleting Todos + +Let's add a delete button to each todo so users can remove items one at a time. + +## Adding the Delete Handler + +First, create the delete function in `App`: + +```typescript +const deleteTodo = (id: number) => + todos.update(items => items.filter(t => t.id !== id)); +``` + +This filters out the todo with the matching ID, effectively removing it. The function returns an Effect. + +## Updating TodoItem Props + +Update `src/components/TodoItem.ts` to accept an `onDelete` callback: + +```typescript +import { $, collect, Readable } from "@effex/dom"; +import { Effect } from "effect"; + +interface Todo { + id: number; + text: string; + completed: boolean; +} + +interface TodoItemProps { + todo: Readable; + onToggle: (id: number) => Effect.Effect; + onDelete: (id: number) => Effect.Effect; +} + +export const TodoItem = (props: TodoItemProps) => + Effect.gen(function* () { + const { todo, onToggle, onDelete } = props; + const todoId = yield* Readable.map(todo, t => t.id).get; + + return yield* $.li( + { + class: Readable.map(todo, t => + t.completed ? "todo-item completed" : "todo-item" + ), + }, + collect( + $.input({ + type: "checkbox", + class: "toggle", + checked: Readable.map(todo, t => t.completed), + onChange: () => onToggle(todoId), + }), + $.span({ class: "todo-text" }, $.of(Readable.map(todo, t => t.text))), + $.button( + { + class: "delete-btn", + onClick: () => onDelete(todoId), + }, + $.of("×") + ), + ) + ); + }); +``` + +We added: +- `onDelete` prop +- A delete button with an × character + +## Passing onDelete to TodoItem + +Update the `each` in `main.ts`: + +```typescript +each(filteredTodos, { + key: (todo) => todo.id, + render: (todo) => TodoItem({ + todo, + onToggle: toggleTodo, + onDelete: deleteTodo, + }), +}) +``` + +## Styling the Delete Button + +Add styles to show the button on hover: + +```css +.todo-item { + display: flex; + align-items: center; + gap: 12px; + padding: 12px; + border-bottom: 1px solid #eee; + position: relative; +} + +.delete-btn { + position: absolute; + right: 12px; + background: none; + border: none; + color: #cc9a9a; + font-size: 24px; + cursor: pointer; + opacity: 0; + transition: opacity 0.2s, color 0.2s; +} + +.todo-item:hover .delete-btn { + opacity: 1; +} + +.delete-btn:hover { + color: #af5b5e; +} +``` + +The delete button is hidden by default and appears when hovering over the todo item. + +## Try It Out + +1. Hover over a todo item +2. Click the × button +3. The todo disappears instantly +4. The count updates automatically + +## How It Works + +The flow is simple: + +1. User clicks delete button → `onDelete(todoId)` called +2. `deleteTodo` runs → `todos.update(items => items.filter(...))` +3. `todos` Signal updates with the item removed +4. `each` detects the change and removes the DOM element +5. All derived values (count, hasCompleted, etc.) update automatically + +## Key Takeaways + +1. **Delete** by filtering the item out of the array +2. **Pass handlers down** just like toggle +3. **CSS hover states** work great for revealing actions +4. The **DOM updates automatically** when items are removed + +[← Previous: Conditional Rendering](./08-conditional-rendering.md) | [Next: Persistence →](./10-persistence.md) diff --git a/apps/docs/content/todo-app/10-persistence.md b/apps/docs/content/todo-app/10-persistence.md new file mode 100644 index 00000000..b5c5bb4f --- /dev/null +++ b/apps/docs/content/todo-app/10-persistence.md @@ -0,0 +1,314 @@ +--- +title: "Chapter 10: Persistence" +description: "Save todos to localStorage using a proper Effect-based persistence layer" +order: 10 +--- + +# Chapter 10: Persistence + +Our todo app works great, but todos disappear on refresh. Let's fix that with a proper persistence layer that follows Effect patterns. + +## Defining the Storage Layer + +First, let's create a typed storage service. Create `src/services/TodoStorage.ts`: + +```typescript +import { Context, Effect } from "effect"; + +interface Todo { + id: number; + text: string; + completed: boolean; +} + +// Error types +class StorageReadError { + readonly _tag = "StorageReadError"; + constructor(readonly cause: unknown) {} +} + +class StorageWriteError { + readonly _tag = "StorageWriteError"; + constructor(readonly cause: unknown) {} +} + +type StorageError = StorageReadError | StorageWriteError; + +// Define the service interface +class TodoStorage extends Context.Tag("TodoStorage")< + TodoStorage, + { + load: Effect.Effect; + save: (todos: Todo[]) => Effect.Effect; + } +>() {} + +export { TodoStorage, StorageReadError, StorageWriteError, type StorageError }; +``` + +Now the implementation using localStorage: + +```typescript +// LocalStorage implementation +const STORAGE_KEY = "effex-todos"; + +const TodoStorageLive = { + load: Effect.try({ + try: () => { + const saved = localStorage.getItem(STORAGE_KEY); + return saved ? JSON.parse(saved) : []; + }, + catch: (error) => new StorageReadError(error), + }), + + save: (todos: Todo[]) => + Effect.try({ + try: () => { + localStorage.setItem(STORAGE_KEY, JSON.stringify(todos)); + }, + catch: (error) => new StorageWriteError(error), + }), +}; + +export { TodoStorageLive }; +``` + +Notice: +- `load` returns `Effect` +- `save` returns `Effect` +- Errors are typed and trackable! + +## Using the Storage Layer + +Now update `src/main.ts` to use our storage service: + +```typescript +import "./styles.css"; +import { Context, Effect, Layer, Option } from "effect"; + +import { + $, + collect, + each, + matchOption, + Readable, + Signal, + when +} from "@effex/dom"; + +import { TodoItem } from "./components/TodoItem"; +import { TodoStorage, TodoStorageLive } from "./services/TodoStorage"; + +// ... Todo interface, Filter type ... + +const App = () => + Effect.gen(function* () { + // 1. Load saved todos from storage + const storage = yield* TodoStorage; + // use Option instead of string | null for better type safety + const error = yield* Signal.make>(Option.none()); + + // Load initial todos with error handling + // Note: this doesn't need to be in a "hook" because Effect.gen runs once + const initialTodos = yield* storage.load.pipe( + // You can handle specific errors by tag + Effect.catchTag('StorageReadError', (err) => + Effect.gen(function* () { + console.error("Failed to load todos:", err); + yield* error.set(Option.some("Failed to load todos from storage.")); + return [] as Todo[]; + }), + ), + Effect.catchAll((err) => + Effect.gen(function* () { + console.error("Failed to load todos:", err); + yield* error.set(Option.some("Unexpected error loading todos.")); + return [] as Todo[]; + }), + ), + ); + + // 2. Create Signal.Array from loaded data + const todos = yield* Signal.Array.make(initialTodos); + + // 3. Set up auto-save reaction + yield* Readable.tap(todos, (currentTodos) => + storage.save(currentTodos).pipe( + Effect.tap(() => error.set(Option.none())), // Clear previous errors on success + Effect.catchTag('StorageWriteError', (err) => { + console.error("Failed to save todos:", err); + return error.set(Option.some("Failed to save todos to storage.")); + }), + Effect.catchAll((err) => { + console.error("Failed to save todos:", err); + return error.set(Option.some("Unexpected error saving todos.")); + }), + ) + ); + + // Rest of the app stays the same... + const newTodoText = yield* Signal.make(""); + const filter = yield* Signal.make("all"); + + return yield* $.div({ class: "todo-app" }, + collect( + // ... header, todo list, footer ... + + // Display error messages if any + matchOption(error, { + onSome: (err) => + $.div({ class: "error-message" }, $.of(err)), + onNone: () => $.div() + }), + + // ... rest of the app (footer, etc.) ... + ) + ); + }); +``` + +## The Three-Step Pattern + +Let's break down what's happening: + +### 1. Load from Storage + +```typescript +const storage = yield* TodoStorage; +const initialTodos = yield* storage.load.pipe( + Effect.catchAll(() => Effect.succeed([])) +); +``` + +We yield the `TodoStorage` service from context, then load todos. If loading fails, we gracefully fall back to an empty array. + +### 2. Initialize Signal.Array + +```typescript +const todos = yield* Signal.Array.make(initialTodos); +``` + +`Signal.Array` is optimized for array operations—it provides `push`, `remove`, `filter`, and other methods that update the array efficiently. + +### 3. React to Changes + +```typescript +yield* Readable.tap(todos, (currentTodos) => + storage.save(currentTodos).pipe( + Effect.catchAll((err) => Effect.sync(() => { + console.error("Failed to save todos:", err); + })) + ) +); +``` + +`Readable.tap` runs whenever `todos` changes. We save to storage and handle any errors gracefully—a failed save shouldn't crash the app. + +## Providing the Service + +Finally, provide the storage implementation when mounting: + +```typescript +const container = document.getElementById("root"); +if (!container) throw new Error("Root element not found"); + +// Create the storage layer +const StorageLayer = Layer.succeed(TodoStorage, TodoStorageLive); + +runApp( + mount( + App().pipe(Effect.provide(StorageLayer)), + container + ); +); +``` + +## Using Signal.Array Methods + +With `Signal.Array`, you can simplify your handlers: + +```typescript +// Before (with regular Signal) +const addTodo = () => + Effect.gen(function* () { + const text = yield* newTodoText.get; + if (text.trim()) { + yield* todos.update(items => [...items, { id: Date.now(), text: text.trim(), completed: false }]); + yield* newTodoText.set(""); + } + }); + +// After (with Signal.Array) +const addTodo = () => + Effect.gen(function* () { + const text = yield* newTodoText.get; + if (text.trim()) { + yield* todos.push({ id: Date.now(), text: text.trim(), completed: false }); + yield* newTodoText.set(""); + } + }); + +// Delete becomes simpler too +const deleteTodo = (id: number) => + todos.filter(t => t.id !== id); + +// Toggle +const toggleTodo = (id: number) => + todos.modify( + t => t.id === id, + t => ({ ...t, completed: !t.completed }) + ); +``` + +## Why This Approach? + +**Type-safe errors**: Storage operations can fail. With Effect, we know exactly how they can fail and handle it explicitly. + +**Testable**: Swap `TodoStorageLive` for a mock implementation in tests: + +```typescript +const TodoStorageTest = { + load: Effect.succeed([{ id: 1, text: "Test todo", completed: false }]), + save: () => Effect.void, +}; +``` + +**Separation of concerns**: The component doesn't know about localStorage—it just uses a `TodoStorage` service. You could swap in IndexedDB, a server API, or anything else. + +**Automatic persistence**: `Readable.tap` handles saving automatically. No need to remember to call save after every change. + +## Try It Out + +1. Add some todos +2. Complete a few +3. Refresh the page +4. Your todos persist! + +Check the browser's DevTools → Application → Local Storage to see the saved data. + +## Congratulations! + +You've built a complete todo application with Effex! You learned: + +- **Elements** with the `$` factory +- **Signals** for reactive state +- **Signal.Array** for optimized array operations +- **Components** as plain functions returning Effects +- **each** for rendering lists +- **Derived state** with `Readable.map` and `Readable.zipWith` +- **Conditional rendering** with `when` +- **Event handling** for user interactions +- **`Readable.tap`** for side effects +- **Context** for dependency injection +- **Typed errors** with Effect + +## What's Next? + +- **[Full-Stack Tutorial](/docs/tutorials/social-app)** - Build a social media site with loaders, actions, and server integration +- **[Concepts](/docs/concepts)** - Deep dive into specific topics +- **[Primitives](/docs/primitives)** - Pre-built accessible UI components +- **[API Reference](/docs/api)** - Complete API documentation + +Happy building! + +[← Previous: Deleting Todos](./09-deleting-todos.md) | [Back to Introduction](./00-introduction.md) diff --git a/apps/docs/index.html b/apps/docs/index.html new file mode 100644 index 00000000..b778acca --- /dev/null +++ b/apps/docs/index.html @@ -0,0 +1,12 @@ + + + + + + Effex Docs + + +
    + + + diff --git a/apps/docs/package.json b/apps/docs/package.json new file mode 100644 index 00000000..6975749a --- /dev/null +++ b/apps/docs/package.json @@ -0,0 +1,25 @@ +{ + "name": "docs", + "version": "0.0.1", + "private": true, + "type": "module", + "scripts": { + "dev": "vite", + "build": "vite build && vite build --ssr src/entry.ts", + "preview": "vite preview" + }, + "dependencies": { + "@effect/platform": "^0.94.0", + "@effex/dom": "workspace:*", + "@effex/platform": "workspace:*", + "@effex/router": "workspace:*", + "effect": "3.19.13", + "markdown-it": "^14.1.0" + }, + "devDependencies": { + "@effex/vite-plugin": "workspace:*", + "@types/markdown-it": "^14.1.2", + "typescript": "~5.9.3", + "vite": "^7.0.0" + } +} diff --git a/apps/docs/src/client.ts b/apps/docs/src/client.ts new file mode 100644 index 00000000..92249957 --- /dev/null +++ b/apps/docs/src/client.ts @@ -0,0 +1,9 @@ +import type { Element } from "@effex/dom"; +import { hydrate } from "@effex/dom/hydrate"; + +import { DocLayout } from "./layout.js"; + +hydrate( + DocLayout() as unknown as Element.Element, + document.getElementById("root")!, +); diff --git a/apps/docs/src/content.ts b/apps/docs/src/content.ts new file mode 100644 index 00000000..3b395a76 --- /dev/null +++ b/apps/docs/src/content.ts @@ -0,0 +1,192 @@ +/** + * Content loading utilities for the docs site. + * + * At build time (SSG), these read markdown files from the content/ directory, + * parse frontmatter, and convert to HTML using markdown-it. + */ + +import * as fs from "node:fs"; +import * as path from "node:path"; + +import { Effect } from "effect"; +import MarkdownIt from "markdown-it"; + +const md = new MarkdownIt({ + html: true, + linkify: true, + typographer: true, +}); + +// ─── Types ─────────────────────────────────────────────────────────────────── + +export interface DocPage { + readonly slug: string; + readonly title: string; + readonly description: string; + readonly order: number; + readonly section: string; + readonly html: string; +} + +export interface DocSection { + readonly name: string; + readonly slug: string; + readonly pages: readonly DocPage[]; +} + +// ─── Frontmatter parsing ───────────────────────────────────────────────────── + +const parseFrontmatter = ( + content: string, +): { meta: Record; body: string } => { + const match = content.match(/^---\n([\s\S]*?)\n---\n([\s\S]*)$/); + if (!match) return { meta: {}, body: content }; + + const meta: Record = {}; + for (const line of match[1].split("\n")) { + const colonIdx = line.indexOf(":"); + if (colonIdx === -1) continue; + const key = line.slice(0, colonIdx).trim(); + const value = line + .slice(colonIdx + 1) + .trim() + .replace(/^["']|["']$/g, ""); + meta[key] = value; + } + + return { meta, body: match[2] }; +}; + +// ─── Content directory discovery ───────────────────────────────────────────── + +const CONTENT_DIR = path.resolve( + import.meta.dirname ?? path.dirname(new URL(import.meta.url).pathname), + "..", + "content", +); + +/** + * Load a single markdown file and return a DocPage. + */ +export const loadPage = ( + section: string, + filename: string, +): Effect.Effect => + Effect.sync(() => { + const filePath = section + ? path.join(CONTENT_DIR, section, filename) + : path.join(CONTENT_DIR, filename); + const raw = fs.readFileSync(filePath, "utf-8"); + const { meta, body } = parseFrontmatter(raw); + const html = md.render(body); + + const slug = filename.replace(/\.md$/, ""); + + return { + slug: section ? `${section}/${slug}` : slug, + title: meta.title ?? slug, + description: meta.description ?? "", + order: parseInt(meta.order ?? "0", 10), + section, + html, + }; + }); + +/** + * Discover all doc pages and their sections. + */ +export const discoverPages = (): Effect.Effect => + Effect.sync(() => { + const pages: DocPage[] = []; + + const entries = fs.readdirSync(CONTENT_DIR, { withFileTypes: true }); + + for (const entry of entries) { + if (entry.isFile() && entry.name.endsWith(".md")) { + // Top-level page + const raw = fs.readFileSync( + path.join(CONTENT_DIR, entry.name), + "utf-8", + ); + const { meta, body } = parseFrontmatter(raw); + const slug = entry.name.replace(/\.md$/, ""); + pages.push({ + slug, + title: meta.title ?? slug, + description: meta.description ?? "", + order: parseInt(meta.order ?? "0", 10), + section: "", + html: md.render(body), + }); + } else if (entry.isDirectory()) { + // Section directory + const sectionDir = path.join(CONTENT_DIR, entry.name); + const files = fs + .readdirSync(sectionDir) + .filter((f) => f.endsWith(".md")) + .sort(); + + for (const file of files) { + const raw = fs.readFileSync(path.join(sectionDir, file), "utf-8"); + const { meta, body } = parseFrontmatter(raw); + const slug = file.replace(/\.md$/, ""); + pages.push({ + slug: `${entry.name}/${slug}`, + title: meta.title ?? slug, + description: meta.description ?? "", + order: parseInt(meta.order ?? "0", 10), + section: entry.name, + html: md.render(body), + }); + } + } + } + + return pages; + }); + +/** + * Group pages into sections for navigation. + */ +export const getSections = (pages: DocPage[]): DocSection[] => { + const sectionMap = new Map(); + + for (const page of pages) { + const key = page.section || "_root"; + const existing = sectionMap.get(key) ?? []; + existing.push(page); + sectionMap.set(key, existing); + } + + const sections: DocSection[] = []; + + // Root pages first + const rootPages = sectionMap.get("_root"); + if (rootPages) { + sections.push({ + name: "Guide", + slug: "", + pages: rootPages.sort((a, b) => a.order - b.order), + }); + } + + // Then section directories + for (const [key, sectionPages] of sectionMap) { + if (key === "_root") continue; + const sorted = sectionPages.sort((a, b) => a.order - b.order); + sections.push({ + name: sectionDisplayName(key), + slug: key, + pages: sorted, + }); + } + + return sections; +}; + +const sectionDisplayName = (slug: string): string => { + return slug + .split("-") + .map((w) => w.charAt(0).toUpperCase() + w.slice(1)) + .join(" "); +}; diff --git a/apps/docs/src/entry.ts b/apps/docs/src/entry.ts new file mode 100644 index 00000000..00cb86a3 --- /dev/null +++ b/apps/docs/src/entry.ts @@ -0,0 +1,38 @@ +/** + * SSG entry point for the docs site. + * + * Exports router, app, and document config for buildStaticSite(). + * Also exports a render() function for the dev server. + */ + +import { HttpApp, HttpRouter } from "@effect/platform"; + +import type { Element } from "@effex/dom"; +import { Platform } from "@effex/platform"; + +import { DocLayout } from "./layout.js"; +import { router } from "./routes.js"; + +const documentOptions = { + title: "Effex Docs", + scripts: ["/src/client.ts"], + styles: ["/src/styles.css"], +}; + +// Used by buildStaticSite() at build time +export { router }; +export const app = DocLayout as unknown as () => Element.Element; +export const document = documentOptions; + +// Used by the dev server during development +const effexRoutes = Platform.toHttpRoutes(router, { + app: DocLayout as unknown as () => Element.Element, + document: documentOptions, +}); + +const httpApp = HttpRouter.empty.pipe(HttpRouter.concat(effexRoutes)); +const handler = HttpApp.toWebHandler(httpApp); + +export async function render(request: Request): Promise { + return handler(request); +} diff --git a/apps/docs/src/generated/routes.ts b/apps/docs/src/generated/routes.ts deleted file mode 100644 index 47b9d60d..00000000 --- a/apps/docs/src/generated/routes.ts +++ /dev/null @@ -1,18 +0,0 @@ -// This file is auto-generated by @effex/vite-plugin -// Do not edit manually - -import { Route } from "@effex/platform"; - - -// Route definitions -export const routes = { -} as const; - -// Component map for rendering routes -export const components = { -} as const; - -// Type inference helpers -export type Routes = typeof routes; -export type RouteNames = keyof Routes; -export type AppRouter = import("@effex/platform").RouterInfer; diff --git a/apps/docs/src/layout.ts b/apps/docs/src/layout.ts new file mode 100644 index 00000000..026dc2ab --- /dev/null +++ b/apps/docs/src/layout.ts @@ -0,0 +1,10 @@ +import { $ } from "@effex/dom"; +import { Outlet } from "@effex/router"; + +import { router } from "./routes.js"; + +export const DocLayout = () => + $.div( + { class: "app" }, + Outlet({ router }), + ); diff --git a/apps/docs/src/routes.ts b/apps/docs/src/routes.ts new file mode 100644 index 00000000..763e86ec --- /dev/null +++ b/apps/docs/src/routes.ts @@ -0,0 +1,147 @@ +import { Effect } from "effect"; + +import { $, collect } from "@effex/dom"; +import { Link, Route, Router } from "@effex/router"; + +import { discoverPages, getSections, loadPage } from "./content.js"; + +// ─── Home page ─────────────────────────────────────────────────────────────── + +const HomeRoute = Route.make("/").pipe( + Route.static({ + load: () => + Effect.gen(function* () { + const pages = yield* discoverPages(); + const sections = getSections(pages); + return { sections }; + }), + render: (data) => + $.div( + { class: "home" }, + collect( + $.h1({}, $.of("Effex Documentation")), + $.p( + { class: "lead" }, + $.of( + "A reactive UI framework built on Effect.ts primitives.", + ), + ), + ...data.sections.map((section) => + $.div( + { class: "section-group" }, + collect( + $.h2({}, $.of(section.name)), + $.ul( + {}, + collect( + ...section.pages.map((page) => + $.li( + {}, + Link( + { href: `/docs/${page.slug}` }, + $.of(page.title), + ), + ), + ), + ), + ), + ), + ), + ), + ), + ), + }), +); + +// ─── Doc pages ─────────────────────────────────────────────────────────────── + +const DocRoute = Route.make("/docs/*").pipe( + Route.static({ + paths: () => + Effect.gen(function* () { + const pages = yield* discoverPages(); + return pages.map((p) => ({ "*": p.slug } as Record)); + }), + load: ({ params }) => + Effect.gen(function* () { + const slug = params["*"]; + const parts = slug.split("/"); + const section = parts.length > 1 ? parts.slice(0, -1).join("/") : ""; + const filename = parts[parts.length - 1] + ".md"; + const page = yield* loadPage(section, filename); + + // Load all pages for sidebar navigation + const allPages = yield* discoverPages(); + const sections = getSections(allPages); + + return { page, sections }; + }), + render: (data) => + $.div( + { class: "doc-page" }, + collect( + $.aside( + { class: "sidebar" }, + collect( + $.div( + { class: "sidebar-header" }, + Link({ href: "/" }, $.of("Effex Docs")), + ), + $.nav( + {}, + collect( + ...data.sections.map((section) => + $.div( + { class: "nav-section" }, + collect( + $.h3({}, $.of(section.name)), + $.ul( + {}, + collect( + ...section.pages.map((page) => + $.li( + {}, + Link( + { href: `/docs/${page.slug}` }, + $.of(page.title), + ), + ), + ), + ), + ), + ), + ), + ), + ), + ), + ), + ), + $.main( + { class: "content" }, + collect( + $.article({ + class: "prose", + innerHTML: data.page.html, + }), + ), + ), + ), + ), + }), +); + +// ─── Router ────────────────────────────────────────────────────────────────── + +export const router = Router.empty.pipe( + Router.concat(HomeRoute), + Router.concat(DocRoute), + Router.fallback(() => + $.div( + { class: "not-found" }, + collect( + $.h1({}, $.of("404 — Page Not Found")), + $.p({}, Link({ href: "/" }, $.of("Back to Home"))), + ), + ), + ), +); diff --git a/apps/docs/src/styles.css b/apps/docs/src/styles.css new file mode 100644 index 00000000..d94f647e --- /dev/null +++ b/apps/docs/src/styles.css @@ -0,0 +1,288 @@ +/* ─── Reset & Base ──────────────────────────────────────────────────────── */ + +*, +*::before, +*::after { + box-sizing: border-box; + margin: 0; + padding: 0; +} + +html { + font-family: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, + sans-serif; + font-size: 16px; + line-height: 1.6; + color: #1a1a2e; + background: #fafafa; +} + +a { + color: #5b4fc4; + text-decoration: none; +} +a:hover { + text-decoration: underline; +} + +/* ─── App layout ────────────────────────────────────────────────────────── */ + +.app { + min-height: 100vh; +} + +/* ─── Home page ─────────────────────────────────────────────────────────── */ + +.home { + max-width: 720px; + margin: 0 auto; + padding: 60px 24px; +} + +.home h1 { + font-size: 2.5rem; + font-weight: 700; + margin-bottom: 8px; +} + +.home .lead { + font-size: 1.2rem; + color: #555; + margin-bottom: 48px; +} + +.section-group { + margin-bottom: 32px; +} + +.section-group h2 { + font-size: 1.1rem; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.05em; + color: #888; + margin-bottom: 8px; + padding-bottom: 4px; + border-bottom: 1px solid #e5e5e5; +} + +.section-group ul { + list-style: none; +} + +.section-group li { + padding: 6px 0; +} + +.section-group li a { + font-size: 1rem; +} + +/* ─── Doc page layout ───────────────────────────────────────────────────── */ + +.doc-page { + display: flex; + min-height: 100vh; +} + +/* ─── Sidebar ───────────────────────────────────────────────────────────── */ + +.sidebar { + width: 280px; + flex-shrink: 0; + padding: 24px; + border-right: 1px solid #e5e5e5; + background: #fff; + position: sticky; + top: 0; + height: 100vh; + overflow-y: auto; +} + +.sidebar-header { + margin-bottom: 24px; +} + +.sidebar-header a { + font-size: 1.1rem; + font-weight: 700; + color: #1a1a2e; +} + +.nav-section { + margin-bottom: 20px; +} + +.nav-section h3 { + font-size: 0.75rem; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.05em; + color: #888; + margin-bottom: 6px; +} + +.nav-section ul { + list-style: none; +} + +.nav-section li { + padding: 3px 0; +} + +.nav-section li a { + font-size: 0.9rem; + color: #555; +} + +.nav-section li a:hover { + color: #5b4fc4; +} + +.nav-section li a[data-active-exact="true"] { + color: #5b4fc4; + font-weight: 600; +} + +/* ─── Content ───────────────────────────────────────────────────────────── */ + +.content { + flex: 1; + min-width: 0; + padding: 48px 64px; + max-width: 800px; +} + +/* ─── Prose (markdown content) ──────────────────────────────────────────── */ + +.prose h1 { + font-size: 2rem; + font-weight: 700; + margin-bottom: 16px; + padding-bottom: 8px; + border-bottom: 1px solid #e5e5e5; +} + +.prose h2 { + font-size: 1.5rem; + font-weight: 600; + margin-top: 40px; + margin-bottom: 12px; +} + +.prose h3 { + font-size: 1.2rem; + font-weight: 600; + margin-top: 32px; + margin-bottom: 8px; +} + +.prose p { + margin-bottom: 16px; +} + +.prose ul, +.prose ol { + margin-bottom: 16px; + padding-left: 24px; +} + +.prose li { + margin-bottom: 4px; +} + +.prose code { + font-family: "SF Mono", "Fira Code", "Fira Mono", Menlo, Consolas, monospace; + font-size: 0.9em; + background: #f0f0f5; + padding: 2px 6px; + border-radius: 4px; +} + +.prose pre { + margin-bottom: 20px; + padding: 16px; + background: #1e1e2e; + color: #cdd6f4; + border-radius: 8px; + overflow-x: auto; + font-size: 0.875rem; + line-height: 1.7; +} + +.prose pre code { + background: none; + padding: 0; + border-radius: 0; + color: inherit; +} + +.prose table { + width: 100%; + border-collapse: collapse; + margin-bottom: 20px; +} + +.prose th, +.prose td { + text-align: left; + padding: 8px 12px; + border: 1px solid #e5e5e5; +} + +.prose th { + background: #f5f5f5; + font-weight: 600; +} + +.prose blockquote { + border-left: 3px solid #5b4fc4; + padding: 8px 16px; + margin-bottom: 16px; + background: #f8f7ff; + color: #444; +} + +.prose strong { + font-weight: 600; +} + +.prose hr { + border: none; + border-top: 1px solid #e5e5e5; + margin: 32px 0; +} + +/* ─── 404 ───────────────────────────────────────────────────────────────── */ + +.not-found { + max-width: 720px; + margin: 0 auto; + padding: 120px 24px; + text-align: center; +} + +.not-found h1 { + font-size: 2rem; + margin-bottom: 16px; +} + +/* ─── Responsive ────────────────────────────────────────────────────────── */ + +@media (max-width: 768px) { + .doc-page { + flex-direction: column; + } + + .sidebar { + width: 100%; + height: auto; + position: static; + border-right: none; + border-bottom: 1px solid #e5e5e5; + } + + .content { + padding: 32px 24px; + } +} diff --git a/apps/docs/tsconfig.json b/apps/docs/tsconfig.json new file mode 100644 index 00000000..c623fec9 --- /dev/null +++ b/apps/docs/tsconfig.json @@ -0,0 +1,14 @@ +{ + "compilerOptions": { + "target": "ES2022", + "lib": ["ES2022", "DOM", "DOM.Iterable"], + "module": "ESNext", + "moduleResolution": "bundler", + "strict": true, + "noEmit": true, + "isolatedModules": true, + "skipLibCheck": true, + "esModuleInterop": true + }, + "include": ["src"] +} diff --git a/apps/docs/vite.config.ts b/apps/docs/vite.config.ts new file mode 100644 index 00000000..acf6a9a6 --- /dev/null +++ b/apps/docs/vite.config.ts @@ -0,0 +1,7 @@ +import { defineConfig } from "vite"; + +import { effexPlatform } from "@effex/vite-plugin"; + +export default defineConfig({ + plugins: [effexPlatform({ mode: "ssg", entry: "src/entry.ts" })], +}); diff --git a/benchmarks/derived.bench.ts b/benchmarks/derived.bench.ts deleted file mode 100644 index a77a97b4..00000000 --- a/benchmarks/derived.bench.ts +++ /dev/null @@ -1,174 +0,0 @@ -import { Effect } from "effect"; -import { bench, describe } from "vitest"; - -import { Derived } from "../src/core/Derived/Derived"; -import { Signal } from "../src/core/Signal"; - -describe("Derived", () => { - describe("creation", () => { - bench("create derived from 1 signal", async () => { - await Effect.runPromise( - Effect.scoped( - Effect.gen(function* () { - const signal = yield* Signal.make(0); - yield* Derived.sync([signal], ([n]) => n * 2); - }), - ), - ); - }); - - bench("create derived from 3 signals", async () => { - await Effect.runPromise( - Effect.scoped( - Effect.gen(function* () { - const a = yield* Signal.make(1); - const b = yield* Signal.make(2); - const c = yield* Signal.make(3); - yield* Derived.sync([a, b, c], ([x, y, z]) => x + y + z); - }), - ), - ); - }); - - bench("create 100 derived values", async () => { - await Effect.runPromise( - Effect.scoped( - Effect.gen(function* () { - const signal = yield* Signal.make(0); - for (let i = 0; i < 100; i++) { - yield* Derived.sync([signal], ([n]) => n + i); - } - }), - ), - ); - }); - }); - - describe("chained derivations", () => { - bench("chain of 5 derived values", async () => { - await Effect.runPromise( - Effect.scoped( - Effect.gen(function* () { - const signal = yield* Signal.make(1); - const d1 = yield* Derived.sync([signal], ([n]) => n * 2); - const d2 = yield* Derived.sync([d1], ([n]) => n + 1); - const d3 = yield* Derived.sync([d2], ([n]) => n * 2); - const d4 = yield* Derived.sync([d3], ([n]) => n + 1); - yield* Derived.sync([d4], ([n]) => n * 2); - }), - ), - ); - }); - - bench("chain of 10 derived values", async () => { - await Effect.runPromise( - Effect.scoped( - Effect.gen(function* () { - const signal = yield* Signal.make(1); - let current: Signal | Derived = signal; - for (let i = 0; i < 10; i++) { - current = yield* Derived.sync([current], ([n]) => n + 1); - } - }), - ), - ); - }); - }); - - describe("propagation", () => { - bench("update signal with 1 derived subscriber", async () => { - await Effect.runPromise( - Effect.scoped( - Effect.gen(function* () { - const signal = yield* Signal.make(0); - yield* Derived.sync([signal], ([n]) => n * 2); - yield* signal.set(1); - }), - ), - ); - }); - - bench("update signal with 10 derived subscribers", async () => { - await Effect.runPromise( - Effect.scoped( - Effect.gen(function* () { - const signal = yield* Signal.make(0); - for (let i = 0; i < 10; i++) { - yield* Derived.sync([signal], ([n]) => n + i); - } - yield* signal.set(1); - }), - ), - ); - }); - - bench("update signal with 100 derived subscribers", async () => { - await Effect.runPromise( - Effect.scoped( - Effect.gen(function* () { - const signal = yield* Signal.make(0); - for (let i = 0; i < 100; i++) { - yield* Derived.sync([signal], ([n]) => n + i); - } - yield* signal.set(1); - }), - ), - ); - }); - - bench("10 updates through chain of 5 derived", async () => { - await Effect.runPromise( - Effect.scoped( - Effect.gen(function* () { - const signal = yield* Signal.make(1); - const d1 = yield* Derived.sync([signal], ([n]) => n * 2); - const d2 = yield* Derived.sync([d1], ([n]) => n + 1); - const d3 = yield* Derived.sync([d2], ([n]) => n * 2); - const d4 = yield* Derived.sync([d3], ([n]) => n + 1); - yield* Derived.sync([d4], ([n]) => n * 2); - - for (let i = 0; i < 10; i++) { - yield* signal.set(i); - } - }), - ), - ); - }); - }); - - describe("diamond dependency", () => { - bench("diamond pattern (A -> B,C -> D)", async () => { - await Effect.runPromise( - Effect.scoped( - Effect.gen(function* () { - const a = yield* Signal.make(1); - const b = yield* Derived.sync([a], ([n]) => n * 2); - const c = yield* Derived.sync([a], ([n]) => n * 3); - yield* Derived.sync([b, c], ([x, y]) => x + y); - - yield* a.set(2); - }), - ), - ); - }); - - bench("wide diamond (A -> 10 branches -> D)", async () => { - await Effect.runPromise( - Effect.scoped( - Effect.gen(function* () { - const a = yield* Signal.make(1); - const branches: Derived[] = []; - for (let i = 0; i < 10; i++) { - branches.push(yield* Derived.sync([a], ([n]) => n * (i + 1))); - } - yield* Derived.sync(branches, (values) => - values.reduce((sum, n) => sum + n, 0), - ); - - yield* a.set(2); - }), - ), - ); - }); - }); -}); diff --git a/benchmarks/dom.bench.ts b/benchmarks/dom.bench.ts deleted file mode 100644 index 8160d2cb..00000000 --- a/benchmarks/dom.bench.ts +++ /dev/null @@ -1,186 +0,0 @@ -import { match, when } from "@dom/Control"; -import { $, Component } from "@dom/Element/Element"; -import { Effect } from "effect"; -import { bench, describe } from "vitest"; - -import { Signal } from "@effex/core/Signal"; - -describe("DOM updates", () => { - describe("element creation", () => { - bench("create div with static text", async () => { - await Effect.runPromise( - Effect.scoped( - Effect.gen(function* () { - yield* $.div("Hello World"); - }), - ), - ); - }); - - bench("create div with signal text", async () => { - await Effect.runPromise( - Effect.scoped( - Effect.gen(function* () { - const text = yield* Signal.make("Hello"); - yield* $.div(text); - }), - ), - ); - }); - - bench("create div with static attributes", async () => { - await Effect.runPromise( - Effect.scoped( - Effect.gen(function* () { - yield* $.div({ - class: "container", - id: "main", - "data-value": "test", - }); - }), - ), - ); - }); - - bench("create div with reactive class", async () => { - await Effect.runPromise( - Effect.scoped( - Effect.gen(function* () { - const isActive = yield* Signal.make(false); - yield* $.div({ - class: isActive.map((a) => (a ? "active" : "inactive")), - }); - }), - ), - ); - }); - - bench("create 100 divs", async () => { - await Effect.runPromise( - Effect.scoped( - Effect.gen(function* () { - for (let i = 0; i < 100; i++) { - yield* $.div(`Item ${i}`); - } - }), - ), - ); - }); - }); - - describe("conditional rendering (when)", () => { - bench("create when", async () => { - await Effect.runPromise( - Effect.scoped( - Effect.gen(function* () { - const show = yield* Signal.make(true); - yield* when( - show, - () => $.div("Visible"), - () => $.span(), - ); - }), - ), - ); - }); - }); - - describe("pattern matching (match)", () => { - type Status = "loading" | "success" | "error"; - - bench("create match with 3 cases", async () => { - await Effect.runPromise( - Effect.scoped( - Effect.gen(function* () { - const status = yield* Signal.make("loading"); - yield* match(status, [ - { pattern: "loading", render: () => $.div("Loading...") }, - { pattern: "success", render: () => $.div("Done!") }, - { pattern: "error", render: () => $.div("Error!") }, - ]); - }), - ), - ); - }); - }); - - describe("component creation", () => { - const SimpleComponent: Component.Unit = () => $.div("Simple"); - - const StatefulComponent: Component.Unit = () => - Effect.gen(function* () { - const count = yield* Signal.make(0); - return yield* $.div([ - $.span(count.map(String)), - $.button({ onClick: () => count.update((n) => n + 1) }, "+"), - ]); - }); - - bench("create simple component", async () => { - await Effect.runPromise(Effect.scoped(SimpleComponent())); - }); - - bench("create stateful component", async () => { - await Effect.runPromise(Effect.scoped(StatefulComponent())); - }); - - bench("create 100 simple components", async () => { - await Effect.runPromise( - Effect.scoped( - Effect.gen(function* () { - for (let i = 0; i < 100; i++) { - yield* SimpleComponent(); - } - }), - ), - ); - }); - - bench("create 100 stateful components", async () => { - await Effect.runPromise( - Effect.scoped( - Effect.gen(function* () { - for (let i = 0; i < 100; i++) { - yield* StatefulComponent(); - } - }), - ), - ); - }); - }); - - describe("nested structures", () => { - bench("create 3-level nested structure", async () => { - await Effect.runPromise( - Effect.scoped( - Effect.gen(function* () { - yield* $.div({ class: "level-1" }, [ - $.div({ class: "level-2" }, [ - $.div({ class: "level-3" }, "Content"), - $.div({ class: "level-3" }, "Content"), - ]), - $.div({ class: "level-2" }, [ - $.div({ class: "level-3" }, "Content"), - $.div({ class: "level-3" }, "Content"), - ]), - ]); - }), - ), - ); - }); - - bench("create 5-level nested structure", async () => { - await Effect.runPromise( - Effect.scoped( - Effect.gen(function* () { - yield* $.div([ - $.div([ - $.div([$.div([$.div("Deep content"), $.div("Deep content")])]), - ]), - ]); - }), - ), - ); - }); - }); -}); diff --git a/benchmarks/list.bench.ts b/benchmarks/list.bench.ts deleted file mode 100644 index 15e2b340..00000000 --- a/benchmarks/list.bench.ts +++ /dev/null @@ -1,117 +0,0 @@ -import { each } from "@dom/Control"; -import { $ } from "@dom/Element/Element"; -import { Effect } from "effect"; -import { bench, describe } from "vitest"; - -import { Signal } from "@effex/core/Signal"; - -interface Item { - id: number; - text: string; -} - -const makeItems = (count: number): Item[] => - Array.from({ length: count }, (_, i) => ({ id: i, text: `Item ${i}` })); - -describe("each (list rendering)", () => { - describe("initial render", () => { - bench("render 100 items", async () => { - await Effect.runPromise( - Effect.scoped( - Effect.gen(function* () { - const items = yield* Signal.make(makeItems(100)); - yield* each( - items, - (item) => item.id, - (item) => $.li(item.map((i) => i.text)), - ); - }), - ), - ); - }); - - bench("render 500 items", async () => { - await Effect.runPromise( - Effect.scoped( - Effect.gen(function* () { - const items = yield* Signal.make(makeItems(500)); - yield* each( - items, - (item) => item.id, - (item) => $.li(item.map((i) => i.text)), - ); - }), - ), - ); - }); - - bench("render 1000 items", async () => { - await Effect.runPromise( - Effect.scoped( - Effect.gen(function* () { - const items = yield* Signal.make(makeItems(1000)); - yield* each( - items, - (item) => item.id, - (item) => $.li(item.map((i) => i.text)), - ); - }), - ), - ); - }); - }); - - describe("complex items", () => { - bench("render 100 items with nested elements", async () => { - await Effect.runPromise( - Effect.scoped( - Effect.gen(function* () { - const items = yield* Signal.make(makeItems(100)); - yield* each( - items, - (item) => item.id, - (item) => - $.li({ class: "item" }, [ - $.span( - { class: "id" }, - item.map((i) => String(i.id)), - ), - $.span( - { class: "text" }, - item.map((i) => i.text), - ), - $.button({ class: "action" }, "Click"), - ]), - ); - }), - ), - ); - }); - - bench("render 500 items with nested elements", async () => { - await Effect.runPromise( - Effect.scoped( - Effect.gen(function* () { - const items = yield* Signal.make(makeItems(500)); - yield* each( - items, - (item) => item.id, - (item) => - $.li({ class: "item" }, [ - $.span( - { class: "id" }, - item.map((i) => String(i.id)), - ), - $.span( - { class: "text" }, - item.map((i) => i.text), - ), - $.button({ class: "action" }, "Click"), - ]), - ); - }), - ), - ); - }); - }); -}); diff --git a/benchmarks/profile.bench.ts b/benchmarks/profile.bench.ts deleted file mode 100644 index 8d5784ad..00000000 --- a/benchmarks/profile.bench.ts +++ /dev/null @@ -1,165 +0,0 @@ -import { Effect, ExecutionStrategy, Scope } from "effect"; -import { bench, describe } from "vitest"; - -import { Signal } from "@effex/core/Signal"; - -// Profile individual operations to find the bottleneck - -describe("Profile bottlenecks", () => { - describe("Scope operations", () => { - bench("Scope.make only", async () => { - await Effect.runPromise( - Effect.scoped( - Effect.gen(function* () { - yield* Scope.make(); - }), - ), - ); - }); - - bench("100x Scope.make", async () => { - await Effect.runPromise( - Effect.scoped( - Effect.gen(function* () { - for (let i = 0; i < 100; i++) { - yield* Scope.make(); - } - }), - ), - ); - }); - - bench("1000x Scope.make", async () => { - await Effect.runPromise( - Effect.scoped( - Effect.gen(function* () { - for (let i = 0; i < 1000; i++) { - yield* Scope.make(); - } - }), - ), - ); - }); - - bench("100x Scope.fork", async () => { - await Effect.runPromise( - Effect.scoped( - Effect.gen(function* () { - const parent = yield* Effect.scope; - for (let i = 0; i < 100; i++) { - yield* Scope.fork(parent, ExecutionStrategy.sequential); - } - }), - ), - ); - }); - - bench("1000x Scope.fork", async () => { - await Effect.runPromise( - Effect.scoped( - Effect.gen(function* () { - const parent = yield* Effect.scope; - for (let i = 0; i < 1000; i++) { - yield* Scope.fork(parent, ExecutionStrategy.sequential); - } - }), - ), - ); - }); - }); - - describe("Effect.provideService overhead", () => { - bench("100x Effect.provideService", async () => { - await Effect.runPromise( - Effect.scoped( - Effect.gen(function* () { - const scope = yield* Scope.make(); - for (let i = 0; i < 100; i++) { - yield* Effect.succeed(i).pipe( - Effect.provideService(Scope.Scope, scope), - ); - } - }), - ), - ); - }); - }); - - describe("Signal creation", () => { - bench("100x Signal.make", async () => { - await Effect.runPromise( - Effect.scoped( - Effect.gen(function* () { - for (let i = 0; i < 100; i++) { - yield* Signal.make(i); - } - }), - ), - ); - }); - }); - - describe("DOM operations (no Effect)", () => { - bench("100x createElement + appendChild", () => { - const container = document.createElement("div"); - for (let i = 0; i < 100; i++) { - const el = document.createElement("li"); - el.textContent = `Item ${i}`; - container.appendChild(el); - } - }); - - bench("1000x createElement + appendChild", () => { - const container = document.createElement("div"); - for (let i = 0; i < 1000; i++) { - const el = document.createElement("li"); - el.textContent = `Item ${i}`; - container.appendChild(el); - } - }); - - bench("100x createElement with fragment", () => { - const container = document.createElement("div"); - const fragment = document.createDocumentFragment(); - for (let i = 0; i < 100; i++) { - const el = document.createElement("li"); - el.textContent = `Item ${i}`; - fragment.appendChild(el); - } - container.appendChild(fragment); - }); - - bench("1000x createElement with fragment", () => { - const container = document.createElement("div"); - const fragment = document.createDocumentFragment(); - for (let i = 0; i < 1000; i++) { - const el = document.createElement("li"); - el.textContent = `Item ${i}`; - fragment.appendChild(el); - } - container.appendChild(fragment); - }); - }); - - describe("Effect.gen overhead", () => { - bench("100x Effect.gen iterations", async () => { - await Effect.runPromise( - Effect.gen(function* () { - for (let i = 0; i < 100; i++) { - yield* Effect.succeed(i); - } - }), - ); - }); - - bench("1000x Effect.gen iterations", async () => { - await Effect.runPromise( - Effect.gen(function* () { - for (let i = 0; i < 1000; i++) { - yield* Effect.succeed(i); - } - }), - ); - }); - }); -}); diff --git a/benchmarks/signal.bench.ts b/benchmarks/signal.bench.ts deleted file mode 100644 index 8b50c707..00000000 --- a/benchmarks/signal.bench.ts +++ /dev/null @@ -1,118 +0,0 @@ -import { Effect } from "effect"; -import { bench, describe } from "vitest"; - -import { Signal } from "../src/core/Signal"; - -describe("Signal", () => { - describe("creation", () => { - bench("create signal", async () => { - await Effect.runPromise( - Effect.scoped( - Effect.gen(function* () { - yield* Signal.make(0); - }), - ), - ); - }); - - bench("create 100 signals", async () => { - await Effect.runPromise( - Effect.scoped( - Effect.gen(function* () { - for (let i = 0; i < 100; i++) { - yield* Signal.make(i); - } - }), - ), - ); - }); - - bench("create 1000 signals", async () => { - await Effect.runPromise( - Effect.scoped( - Effect.gen(function* () { - for (let i = 0; i < 1000; i++) { - yield* Signal.make(i); - } - }), - ), - ); - }); - }); - - describe("updates", () => { - bench("set value", async () => { - await Effect.runPromise( - Effect.scoped( - Effect.gen(function* () { - const signal = yield* Signal.make(0); - yield* signal.set(1); - }), - ), - ); - }); - - bench("update value", async () => { - await Effect.runPromise( - Effect.scoped( - Effect.gen(function* () { - const signal = yield* Signal.make(0); - yield* signal.update((n) => n + 1); - }), - ), - ); - }); - - bench("100 sequential updates", async () => { - await Effect.runPromise( - Effect.scoped( - Effect.gen(function* () { - const signal = yield* Signal.make(0); - for (let i = 0; i < 100; i++) { - yield* signal.set(i); - } - }), - ), - ); - }); - - bench("1000 sequential updates", async () => { - await Effect.runPromise( - Effect.scoped( - Effect.gen(function* () { - const signal = yield* Signal.make(0); - for (let i = 0; i < 1000; i++) { - yield* signal.set(i); - } - }), - ), - ); - }); - }); - - describe("reads", () => { - bench("get value", async () => { - await Effect.runPromise( - Effect.scoped( - Effect.gen(function* () { - const signal = yield* Signal.make(42); - yield* signal.get; - }), - ), - ); - }); - - bench("100 sequential reads", async () => { - await Effect.runPromise( - Effect.scoped( - Effect.gen(function* () { - const signal = yield* Signal.make(42); - for (let i = 0; i < 100; i++) { - yield* signal.get; - } - }), - ), - ); - }); - }); -}); diff --git a/benchmarks/virtual-list.bench.ts b/benchmarks/virtual-list.bench.ts deleted file mode 100644 index da01a4b8..00000000 --- a/benchmarks/virtual-list.bench.ts +++ /dev/null @@ -1,151 +0,0 @@ -import { each } from "@dom/Control"; -import { $ } from "@dom/Element/Element"; -import { virtualEach } from "@dom/VirtualList"; -import { Effect } from "effect"; -import { bench, describe } from "vitest"; - -import { Signal } from "@effex/core/Signal"; - -interface Item { - id: string; - text: string; -} - -const makeItems = (count: number): Item[] => - Array.from({ length: count }, (_, i) => ({ - id: `item-${i}`, - text: `Item ${i}`, - })); - -describe("Virtual List vs Regular List", () => { - describe("initial render comparison", () => { - bench("virtualEach: 1000 items", async () => { - await Effect.runPromise( - Effect.scoped( - Effect.gen(function* () { - const items = yield* Signal.make(makeItems(1000)); - yield* virtualEach(items, { - key: (item) => item.id, - itemHeight: 50, - height: 400, - render: (item) => $.li(item.map((i) => i.text)), - }); - }), - ), - ); - }); - - bench("each: 1000 items (for comparison)", async () => { - await Effect.runPromise( - Effect.scoped( - Effect.gen(function* () { - const items = yield* Signal.make(makeItems(1000)); - yield* each( - items, - (item) => item.id, - (item) => $.li(item.map((i) => i.text)), - ); - }), - ), - ); - }); - - bench("virtualEach: 10000 items", async () => { - await Effect.runPromise( - Effect.scoped( - Effect.gen(function* () { - const items = yield* Signal.make(makeItems(10000)); - yield* virtualEach(items, { - key: (item) => item.id, - itemHeight: 50, - height: 400, - render: (item) => $.li(item.map((i) => i.text)), - }); - }), - ), - ); - }); - }); - - describe("virtualEach only", () => { - bench("100 items", async () => { - await Effect.runPromise( - Effect.scoped( - Effect.gen(function* () { - const items = yield* Signal.make(makeItems(100)); - yield* virtualEach(items, { - key: (item) => item.id, - itemHeight: 50, - height: 400, - render: (item) => $.li(item.map((i) => i.text)), - }); - }), - ), - ); - }); - - bench("500 items", async () => { - await Effect.runPromise( - Effect.scoped( - Effect.gen(function* () { - const items = yield* Signal.make(makeItems(500)); - yield* virtualEach(items, { - key: (item) => item.id, - itemHeight: 50, - height: 400, - render: (item) => $.li(item.map((i) => i.text)), - }); - }), - ), - ); - }); - - bench("1000 items", async () => { - await Effect.runPromise( - Effect.scoped( - Effect.gen(function* () { - const items = yield* Signal.make(makeItems(1000)); - yield* virtualEach(items, { - key: (item) => item.id, - itemHeight: 50, - height: 400, - render: (item) => $.li(item.map((i) => i.text)), - }); - }), - ), - ); - }); - - bench("5000 items", async () => { - await Effect.runPromise( - Effect.scoped( - Effect.gen(function* () { - const items = yield* Signal.make(makeItems(5000)); - yield* virtualEach(items, { - key: (item) => item.id, - itemHeight: 50, - height: 400, - render: (item) => $.li(item.map((i) => i.text)), - }); - }), - ), - ); - }); - - bench("10000 items", async () => { - await Effect.runPromise( - Effect.scoped( - Effect.gen(function* () { - const items = yield* Signal.make(makeItems(10000)); - yield* virtualEach(items, { - key: (item) => item.id, - itemHeight: 50, - height: 400, - render: (item) => $.li(item.map((i) => i.text)), - }); - }), - ), - ); - }); - }); -}); diff --git a/package.json b/package.json index 5e7b6577..3f37f4dc 100644 --- a/package.json +++ b/package.json @@ -24,7 +24,7 @@ "changeset": "changeset", "version": "changeset version", "release": "pnpm build && changeset publish", - "docs:gen": "rm -rf docs && typedoc", + "docgen": "rm -rf docs && typedoc", "prepare": "husky" }, "lint-staged": { diff --git a/packages/platform/src/Platform.ts b/packages/platform/src/Platform.ts index e0214214..3ee0305e 100644 --- a/packages/platform/src/Platform.ts +++ b/packages/platform/src/Platform.ts @@ -289,7 +289,11 @@ export const toHttpRoutes = ( params: unknown; searchParams: unknown; }) => Effect.Effect) - : null; + : (route as any)._staticConfig?.load + ? ((route as any)._staticConfig.load as (args: { + params: unknown; + }) => Effect.Effect) + : null; if (loaderFn) { const loaderOrRedirect = yield* loaderFn({ diff --git a/packages/vite-plugin/package.json b/packages/vite-plugin/package.json index 9d942135..e8b0182e 100644 --- a/packages/vite-plugin/package.json +++ b/packages/vite-plugin/package.json @@ -35,8 +35,14 @@ "build": "tsup" }, "peerDependencies": { + "@effex/platform": "workspace:*", "vite": "^5.0.0 || ^6.0.0 || ^7.0.0" }, + "peerDependenciesMeta": { + "@effex/platform": { + "optional": true + } + }, "devDependencies": { "vite": "^7.0.0" } diff --git a/packages/vite-plugin/src/plugin.ts b/packages/vite-plugin/src/plugin.ts index 279335ed..480cca33 100644 --- a/packages/vite-plugin/src/plugin.ts +++ b/packages/vite-plugin/src/plugin.ts @@ -74,6 +74,7 @@ export const effexPlatform = (options: EffexPlatformOptions = {}): Plugin => { const exclude = options.exclude; const mode = options.mode ?? "ssr"; let isSsr = false; + let isDev = false; let root: string; let outDir: string; let entryPath: string | null = null; @@ -85,6 +86,7 @@ export const effexPlatform = (options: EffexPlatformOptions = {}): Plugin => { root = config.root; outDir = path.resolve(root, config.build?.outDir ?? "dist"); isSsr = !!config.build?.ssr; + isDev = config.command === "serve"; if (options.entry) { entryPath = path.resolve(root, options.entry); } @@ -241,7 +243,7 @@ export const effexPlatform = (options: EffexPlatformOptions = {}): Plugin => { // ------------------------------------------------------------------------- async closeBundle() { - if (mode !== "ssg" || !entryPath || isSsr) return; + if (mode !== "ssg" || !entryPath || isSsr || isDev) return; try { // Dynamically import the built SSG entry. diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 785983bf..0ade4dd7 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -81,126 +81,33 @@ importers: specifier: ^4.0.16 version: 4.0.16(@types/node@24.10.4)(@vitest/browser-playwright@4.0.16)(jiti@2.6.1)(jsdom@27.3.0)(lightningcss@1.30.2)(tsx@4.21.0)(yaml@2.8.2) - examples/kanban: - dependencies: - '@effex/dom': - specifier: workspace:* - version: link:../../packages/dom - '@effex/form': - specifier: workspace:* - version: link:../../packages/form - '@tailwindcss/vite': - specifier: ^4.1.18 - version: 4.1.18(vite@7.3.0(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)(yaml@2.8.2)) - effect: - specifier: ^3.0.0 - version: 3.19.13 - tailwindcss: - specifier: ^4.1.18 - version: 4.1.18 - devDependencies: - daisyui: - specifier: ^5.5.18 - version: 5.5.18 - typescript: - specifier: ~5.9.3 - version: 5.9.3 - vite: - specifier: ^7.0.0 - version: 7.3.0(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)(yaml@2.8.2) - - examples/router-demo: - dependencies: - '@effex/core': - specifier: workspace:* - version: link:../../packages/core - '@effex/dom': - specifier: workspace:* - version: link:../../packages/dom - '@effex/router': - specifier: workspace:* - version: link:../../packages/router - '@tailwindcss/vite': - specifier: ^4.1.18 - version: 4.1.18(vite@7.3.0(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)(yaml@2.8.2)) - effect: - specifier: ^3.0.0 - version: 3.19.13 - tailwindcss: - specifier: ^4.1.18 - version: 4.1.18 - devDependencies: - typescript: - specifier: ~5.9.3 - version: 5.9.3 - vite: - specifier: ^7.0.0 - version: 7.3.0(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)(yaml@2.8.2) - - examples/todo-app: - dependencies: - '@effex/dom': - specifier: workspace:* - version: link:../../packages/dom - '@tailwindcss/vite': - specifier: ^4.1.18 - version: 4.1.18(vite@7.3.0(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)(yaml@2.8.2)) - effect: - specifier: ^3.0.0 - version: 3.19.13 - tailwindcss: - specifier: ^4.1.18 - version: 4.1.18 - devDependencies: - daisyui: - specifier: ^5.5.18 - version: 5.5.18 - typescript: - specifier: ~5.9.3 - version: 5.9.3 - vite: - specifier: ^7.0.0 - version: 7.3.0(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)(yaml@2.8.2) - - examples/twitter: + apps/docs: dependencies: '@effect/platform': specifier: ^0.94.0 version: 0.94.0(effect@3.19.13) - '@effect/platform-node': - specifier: ^0.104.0 - version: 0.104.1(@effect/cluster@0.56.4(@effect/platform@0.94.0(effect@3.19.13))(@effect/rpc@0.73.2(@effect/platform@0.94.0(effect@3.19.13))(effect@3.19.13))(@effect/sql@0.49.0(@effect/experimental@0.58.0(@effect/platform@0.94.0(effect@3.19.13))(effect@3.19.13))(@effect/platform@0.94.0(effect@3.19.13))(effect@3.19.13))(@effect/workflow@0.16.0(@effect/experimental@0.58.0(@effect/platform@0.94.0(effect@3.19.13))(effect@3.19.13))(@effect/platform@0.94.0(effect@3.19.13))(@effect/rpc@0.73.2(@effect/platform@0.94.0(effect@3.19.13))(effect@3.19.13))(effect@3.19.13))(effect@3.19.13))(@effect/platform@0.94.0(effect@3.19.13))(@effect/rpc@0.73.2(@effect/platform@0.94.0(effect@3.19.13))(effect@3.19.13))(@effect/sql@0.49.0(@effect/experimental@0.58.0(@effect/platform@0.94.0(effect@3.19.13))(effect@3.19.13))(@effect/platform@0.94.0(effect@3.19.13))(effect@3.19.13))(effect@3.19.13) '@effex/dom': specifier: workspace:* version: link:../../packages/dom - '@effex/form': - specifier: workspace:* - version: link:../../packages/form '@effex/platform': specifier: workspace:* version: link:../../packages/platform '@effex/router': specifier: workspace:* version: link:../../packages/router - '@tailwindcss/vite': - specifier: ^4.1.18 - version: 4.1.18(vite@7.3.0(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)(yaml@2.8.2)) effect: - specifier: ^3.19.13 + specifier: 3.19.13 version: 3.19.13 - tailwindcss: - specifier: ^4.1.18 - version: 4.1.18 + markdown-it: + specifier: ^14.1.0 + version: 14.1.0 devDependencies: '@effex/vite-plugin': specifier: workspace:* version: link:../../packages/vite-plugin - daisyui: - specifier: ^5.5.18 - version: 5.5.18 - tsx: - specifier: ^4.19.0 - version: 4.21.0 + '@types/markdown-it': + specifier: ^14.1.2 + version: 14.1.2 typescript: specifier: ~5.9.3 version: 5.9.3 @@ -276,6 +183,10 @@ importers: version: 3.19.13 packages/vite-plugin: + dependencies: + '@effex/platform': + specifier: workspace:* + version: link:../platform devDependencies: vite: specifier: ^7.0.0 @@ -436,78 +347,17 @@ packages: resolution: {integrity: sha512-Vd/9EVDiu6PPJt9yAh6roZP6El1xHrdvIVGjyBsHR0RYwNHgL7FJPyIIW4fANJNG6FtyZfvlRPpFI4ZM/lubvw==} engines: {node: '>=18'} - '@effect/cluster@0.56.4': - resolution: {integrity: sha512-7Je5/JlbZOlsSxsbKjr97dJed2cNGWsb+TLNgMcr5mRDbcWlFOTUGvsrisEJV6waosYLIg+2omPdvnvRoYKdhA==} - peerDependencies: - '@effect/platform': ^0.94.5 - '@effect/rpc': ^0.73.1 - '@effect/sql': ^0.49.0 - '@effect/workflow': ^0.16.0 - effect: ^3.19.17 - - '@effect/experimental@0.58.0': - resolution: {integrity: sha512-IEP9sapjF6rFy5TkoqDPc86st/fnqUfjT7Xa3pWJrFGr1hzaMXHo+mWsYOZS9LAOVKnpHuVziDK97EP5qsCHVA==} - peerDependencies: - '@effect/platform': ^0.94.0 - effect: ^3.19.13 - ioredis: ^5 - lmdb: ^3 - peerDependenciesMeta: - ioredis: - optional: true - lmdb: - optional: true - - '@effect/platform-node-shared@0.57.1': - resolution: {integrity: sha512-oX/bApMdoKsyrDiNdJxo7U9Rz1RXsjRv+ecfAPp1qGlSdGIo32wVRvJ2XCHqYj0sqaYJS0pU0/GCulRfVGuJag==} - peerDependencies: - '@effect/cluster': ^0.56.1 - '@effect/platform': ^0.94.2 - '@effect/rpc': ^0.73.0 - '@effect/sql': ^0.49.0 - effect: ^3.19.15 - - '@effect/platform-node@0.104.1': - resolution: {integrity: sha512-jT1a/z98niK6fnEU8pWHPPCdJMVDRCIdB65lolcOjse5rsTwVbczMjvKkhVQpF63mNWoOnol7OTRNkw5L54llg==} - peerDependencies: - '@effect/cluster': ^0.56.1 - '@effect/platform': ^0.94.2 - '@effect/rpc': ^0.73.0 - '@effect/sql': ^0.49.0 - effect: ^3.19.15 - '@effect/platform@0.94.0': resolution: {integrity: sha512-oIATd3M+RUe2q+bu0Qpgt4/qSbXBM9OEGBrRW7SvtwXGOWkCYkN+LfOPnmPrbMB7jYjpIb7808T1bONM1Nwgfg==} peerDependencies: effect: ^3.19.13 - '@effect/rpc@0.73.2': - resolution: {integrity: sha512-td7LHDgBOYKg+VgGWEelD8rSAmvjXz7am17vfxZROX5qIYuvH7drL/z4p5xQFadhHZ7DYdlFpqdO9ggc77OCIw==} - peerDependencies: - '@effect/platform': ^0.94.5 - effect: ^3.19.18 - - '@effect/sql@0.49.0': - resolution: {integrity: sha512-9UEKR+z+MrI/qMAmSvb/RiD9KlgIazjZUCDSpwNgm0lEK9/Q6ExEyfziiYFVCPiptp52cBw8uBHRic8hHnwqXA==} - peerDependencies: - '@effect/experimental': ^0.58.0 - '@effect/platform': ^0.94.0 - effect: ^3.19.13 - '@effect/vitest@0.27.0': resolution: {integrity: sha512-8bM7n9xlMUYw9GqPIVgXFwFm2jf27m/R7psI64PGpwU5+26iwyxp9eAXEsfT5S6lqztYfpQQ1Ubp5o6HfNYzJQ==} peerDependencies: effect: ^3.19.0 vitest: ^3.2.0 - '@effect/workflow@0.16.0': - resolution: {integrity: sha512-MiAdlxx3TixkgHdbw+Yf1Z3tHAAE0rOQga12kIydJqj05Fnod+W/I+kQGRMY/XWRg+QUsVxhmh1qTr7Ype6lrw==} - peerDependencies: - '@effect/experimental': ^0.58.0 - '@effect/platform': ^0.94.0 - '@effect/rpc': ^0.73.0 - effect: ^3.19.13 - '@esbuild/aix-ppc64@0.27.2': resolution: {integrity: sha512-GZMB+a0mOMZs4MpDbj8RJp4cw+w1WV5NYD6xzgvzUJ5Ek2jerwfO2eADyI6ExDSUED+1X8aMbegahsJi+8mgpw==} engines: {node: '>=18'} @@ -759,9 +609,6 @@ packages: '@jridgewell/gen-mapping@0.3.13': resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==} - '@jridgewell/remapping@2.3.5': - resolution: {integrity: sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==} - '@jridgewell/resolve-uri@3.1.2': resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==} engines: {node: '>=6.0.0'} @@ -820,88 +667,6 @@ packages: resolution: {integrity: sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==} engines: {node: '>= 8'} - '@parcel/watcher-android-arm64@2.5.6': - resolution: {integrity: sha512-YQxSS34tPF/6ZG7r/Ih9xy+kP/WwediEUsqmtf0cuCV5TPPKw/PQHRhueUo6JdeFJaqV3pyjm0GdYjZotbRt/A==} - engines: {node: '>= 10.0.0'} - cpu: [arm64] - os: [android] - - '@parcel/watcher-darwin-arm64@2.5.6': - resolution: {integrity: sha512-Z2ZdrnwyXvvvdtRHLmM4knydIdU9adO3D4n/0cVipF3rRiwP+3/sfzpAwA/qKFL6i1ModaabkU7IbpeMBgiVEA==} - engines: {node: '>= 10.0.0'} - cpu: [arm64] - os: [darwin] - - '@parcel/watcher-darwin-x64@2.5.6': - resolution: {integrity: sha512-HgvOf3W9dhithcwOWX9uDZyn1lW9R+7tPZ4sug+NGrGIo4Rk1hAXLEbcH1TQSqxts0NYXXlOWqVpvS1SFS4fRg==} - engines: {node: '>= 10.0.0'} - cpu: [x64] - os: [darwin] - - '@parcel/watcher-freebsd-x64@2.5.6': - resolution: {integrity: sha512-vJVi8yd/qzJxEKHkeemh7w3YAn6RJCtYlE4HPMoVnCpIXEzSrxErBW5SJBgKLbXU3WdIpkjBTeUNtyBVn8TRng==} - engines: {node: '>= 10.0.0'} - cpu: [x64] - os: [freebsd] - - '@parcel/watcher-linux-arm-glibc@2.5.6': - resolution: {integrity: sha512-9JiYfB6h6BgV50CCfasfLf/uvOcJskMSwcdH1PHH9rvS1IrNy8zad6IUVPVUfmXr+u+Km9IxcfMLzgdOudz9EQ==} - engines: {node: '>= 10.0.0'} - cpu: [arm] - os: [linux] - - '@parcel/watcher-linux-arm-musl@2.5.6': - resolution: {integrity: sha512-Ve3gUCG57nuUUSyjBq/MAM0CzArtuIOxsBdQ+ftz6ho8n7s1i9E1Nmk/xmP323r2YL0SONs1EuwqBp2u1k5fxg==} - engines: {node: '>= 10.0.0'} - cpu: [arm] - os: [linux] - - '@parcel/watcher-linux-arm64-glibc@2.5.6': - resolution: {integrity: sha512-f2g/DT3NhGPdBmMWYoxixqYr3v/UXcmLOYy16Bx0TM20Tchduwr4EaCbmxh1321TABqPGDpS8D/ggOTaljijOA==} - engines: {node: '>= 10.0.0'} - cpu: [arm64] - os: [linux] - - '@parcel/watcher-linux-arm64-musl@2.5.6': - resolution: {integrity: sha512-qb6naMDGlbCwdhLj6hgoVKJl2odL34z2sqkC7Z6kzir8b5W65WYDpLB6R06KabvZdgoHI/zxke4b3zR0wAbDTA==} - engines: {node: '>= 10.0.0'} - cpu: [arm64] - os: [linux] - - '@parcel/watcher-linux-x64-glibc@2.5.6': - resolution: {integrity: sha512-kbT5wvNQlx7NaGjzPFu8nVIW1rWqV780O7ZtkjuWaPUgpv2NMFpjYERVi0UYj1msZNyCzGlaCWEtzc+exjMGbQ==} - engines: {node: '>= 10.0.0'} - cpu: [x64] - os: [linux] - - '@parcel/watcher-linux-x64-musl@2.5.6': - resolution: {integrity: sha512-1JRFeC+h7RdXwldHzTsmdtYR/Ku8SylLgTU/reMuqdVD7CtLwf0VR1FqeprZ0eHQkO0vqsbvFLXUmYm/uNKJBg==} - engines: {node: '>= 10.0.0'} - cpu: [x64] - os: [linux] - - '@parcel/watcher-win32-arm64@2.5.6': - resolution: {integrity: sha512-3ukyebjc6eGlw9yRt678DxVF7rjXatWiHvTXqphZLvo7aC5NdEgFufVwjFfY51ijYEWpXbqF5jtrK275z52D4Q==} - engines: {node: '>= 10.0.0'} - cpu: [arm64] - os: [win32] - - '@parcel/watcher-win32-ia32@2.5.6': - resolution: {integrity: sha512-k35yLp1ZMwwee3Ez/pxBi5cf4AoBKYXj00CZ80jUz5h8prpiaQsiRPKQMxoLstNuqe2vR4RNPEAEcjEFzhEz/g==} - engines: {node: '>= 10.0.0'} - cpu: [ia32] - os: [win32] - - '@parcel/watcher-win32-x64@2.5.6': - resolution: {integrity: sha512-hbQlYcCq5dlAX9Qx+kFb0FHue6vbjlf0FrNzSKdYK2APUf7tGfGxQCk2ihEREmbR6ZMc0MVAD5RIX/41gpUzTw==} - engines: {node: '>= 10.0.0'} - cpu: [x64] - os: [win32] - - '@parcel/watcher@2.5.6': - resolution: {integrity: sha512-tmmZ3lQxAe/k/+rNnXQRawJ4NjxO2hqiOLTHvWchtGZULp4RyFeh6aU4XdOYBFe2KE1oShQTv4AblOs2iOrNnQ==} - engines: {node: '>= 10.0.0'} - '@pkgr/core@0.2.9': resolution: {integrity: sha512-QNqXyfVS2wm9hweSYD2O7F0G06uurj9kZ96TRQE5Y9hU7+tgdZwIkbAKc5Ocy1HxEY2kuDQa6cQ1WRs/O5LFKA==} engines: {node: ^12.20.0 || ^14.18.0 || >=16.0.0} @@ -1037,96 +802,6 @@ packages: '@standard-schema/spec@1.1.0': resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} - '@tailwindcss/node@4.1.18': - resolution: {integrity: sha512-DoR7U1P7iYhw16qJ49fgXUlry1t4CpXeErJHnQ44JgTSKMaZUdf17cfn5mHchfJ4KRBZRFA/Coo+MUF5+gOaCQ==} - - '@tailwindcss/oxide-android-arm64@4.1.18': - resolution: {integrity: sha512-dJHz7+Ugr9U/diKJA0W6N/6/cjI+ZTAoxPf9Iz9BFRF2GzEX8IvXxFIi/dZBloVJX/MZGvRuFA9rqwdiIEZQ0Q==} - engines: {node: '>= 10'} - cpu: [arm64] - os: [android] - - '@tailwindcss/oxide-darwin-arm64@4.1.18': - resolution: {integrity: sha512-Gc2q4Qhs660bhjyBSKgq6BYvwDz4G+BuyJ5H1xfhmDR3D8HnHCmT/BSkvSL0vQLy/nkMLY20PQ2OoYMO15Jd0A==} - engines: {node: '>= 10'} - cpu: [arm64] - os: [darwin] - - '@tailwindcss/oxide-darwin-x64@4.1.18': - resolution: {integrity: sha512-FL5oxr2xQsFrc3X9o1fjHKBYBMD1QZNyc1Xzw/h5Qu4XnEBi3dZn96HcHm41c/euGV+GRiXFfh2hUCyKi/e+yw==} - engines: {node: '>= 10'} - cpu: [x64] - os: [darwin] - - '@tailwindcss/oxide-freebsd-x64@4.1.18': - resolution: {integrity: sha512-Fj+RHgu5bDodmV1dM9yAxlfJwkkWvLiRjbhuO2LEtwtlYlBgiAT4x/j5wQr1tC3SANAgD+0YcmWVrj8R9trVMA==} - engines: {node: '>= 10'} - cpu: [x64] - os: [freebsd] - - '@tailwindcss/oxide-linux-arm-gnueabihf@4.1.18': - resolution: {integrity: sha512-Fp+Wzk/Ws4dZn+LV2Nqx3IilnhH51YZoRaYHQsVq3RQvEl+71VGKFpkfHrLM/Li+kt5c0DJe/bHXK1eHgDmdiA==} - engines: {node: '>= 10'} - cpu: [arm] - os: [linux] - - '@tailwindcss/oxide-linux-arm64-gnu@4.1.18': - resolution: {integrity: sha512-S0n3jboLysNbh55Vrt7pk9wgpyTTPD0fdQeh7wQfMqLPM/Hrxi+dVsLsPrycQjGKEQk85Kgbx+6+QnYNiHalnw==} - engines: {node: '>= 10'} - cpu: [arm64] - os: [linux] - - '@tailwindcss/oxide-linux-arm64-musl@4.1.18': - resolution: {integrity: sha512-1px92582HkPQlaaCkdRcio71p8bc8i/ap5807tPRDK/uw953cauQBT8c5tVGkOwrHMfc2Yh6UuxaH4vtTjGvHg==} - engines: {node: '>= 10'} - cpu: [arm64] - os: [linux] - - '@tailwindcss/oxide-linux-x64-gnu@4.1.18': - resolution: {integrity: sha512-v3gyT0ivkfBLoZGF9LyHmts0Isc8jHZyVcbzio6Wpzifg/+5ZJpDiRiUhDLkcr7f/r38SWNe7ucxmGW3j3Kb/g==} - engines: {node: '>= 10'} - cpu: [x64] - os: [linux] - - '@tailwindcss/oxide-linux-x64-musl@4.1.18': - resolution: {integrity: sha512-bhJ2y2OQNlcRwwgOAGMY0xTFStt4/wyU6pvI6LSuZpRgKQwxTec0/3Scu91O8ir7qCR3AuepQKLU/kX99FouqQ==} - engines: {node: '>= 10'} - cpu: [x64] - os: [linux] - - '@tailwindcss/oxide-wasm32-wasi@4.1.18': - resolution: {integrity: sha512-LffYTvPjODiP6PT16oNeUQJzNVyJl1cjIebq/rWWBF+3eDst5JGEFSc5cWxyRCJ0Mxl+KyIkqRxk1XPEs9x8TA==} - engines: {node: '>=14.0.0'} - cpu: [wasm32] - bundledDependencies: - - '@napi-rs/wasm-runtime' - - '@emnapi/core' - - '@emnapi/runtime' - - '@tybys/wasm-util' - - '@emnapi/wasi-threads' - - tslib - - '@tailwindcss/oxide-win32-arm64-msvc@4.1.18': - resolution: {integrity: sha512-HjSA7mr9HmC8fu6bdsZvZ+dhjyGCLdotjVOgLA2vEqxEBZaQo9YTX4kwgEvPCpRh8o4uWc4J/wEoFzhEmjvPbA==} - engines: {node: '>= 10'} - cpu: [arm64] - os: [win32] - - '@tailwindcss/oxide-win32-x64-msvc@4.1.18': - resolution: {integrity: sha512-bJWbyYpUlqamC8dpR7pfjA0I7vdF6t5VpUGMWRkXVE3AXgIZjYUYAK7II1GNaxR8J1SSrSrppRar8G++JekE3Q==} - engines: {node: '>= 10'} - cpu: [x64] - os: [win32] - - '@tailwindcss/oxide@4.1.18': - resolution: {integrity: sha512-EgCR5tTS5bUSKQgzeMClT6iCY3ToqE1y+ZB0AKldj809QXk1Y+3jB0upOYZrn9aGIzPtUsP7sX4QQ4XtjBB95A==} - engines: {node: '>= 10'} - - '@tailwindcss/vite@4.1.18': - resolution: {integrity: sha512-jVA+/UpKL1vRLg6Hkao5jldawNmRo7mQYrZtNHMIVpLfLhDml5nMRUo/8MwoX2vNXvnaXNNMedrMfMugAVX1nA==} - peerDependencies: - vite: ^5.2.0 || ^6 || ^7 - '@types/chai@5.2.3': resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==} @@ -1142,6 +817,15 @@ packages: '@types/json-schema@7.0.15': resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==} + '@types/linkify-it@5.0.0': + resolution: {integrity: sha512-sVDA58zAw4eWAffKOaQH5/5j3XeayukzDk+ewSsnv3p4yJEZHCCzMDiZM8e0OUrRvmpGZ85jf4yDHkHsgBNr9Q==} + + '@types/markdown-it@14.1.2': + resolution: {integrity: sha512-promo4eFwuiW+TfGxhi+0x3czqTYJkG8qB17ZUJiVF10Xm7NLVRSLUsfRTU/6h1e24VvRnXCx+hG7li58lkzog==} + + '@types/mdurl@2.0.0': + resolution: {integrity: sha512-RGdgjQUZba5p6QEFAVx2OGb8rQDL/cPRG7GiedRzMcJ1tYnUANBncjbSB1NRGwbvjcPeikRABz2nshyPk1bhWg==} + '@types/node@12.20.55': resolution: {integrity: sha512-J8xLz7q2OFulZ2cyGTLE1TbbZcjpno7FaN6zdJNrgAdrJ+DZzh/uFR6YrTb4C+nXakvud8Q4+rbhoIWlYQbUFQ==} @@ -1424,9 +1108,6 @@ packages: resolution: {integrity: sha512-GlsEptulso7Jg0VaOZ8BXQi3AkYM5BOJKEO/rjMidSCq70FkIC5y0eawrCXeYzxgt3OCf4Ls+eoxN+/05vN0Ag==} engines: {node: '>=20'} - daisyui@5.5.18: - resolution: {integrity: sha512-VVzjpOitMGB6DWIBeRSapbjdOevFqyzpk9u5Um6a4tyId3JFrU5pbtF0vgjXDth76mJZbueN/j9Ok03SPrh/og==} - data-urls@6.0.0: resolution: {integrity: sha512-BnBS08aLUM+DKamupXs3w2tJJoqU+AkaE/+6vQxi/G/DPmIZFJJp9Dkb1kM03AZx8ADehDUZgsNxju3mPXZYIA==} engines: {node: '>=20'} @@ -1467,10 +1148,6 @@ packages: emoji-regex@10.6.0: resolution: {integrity: sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==} - enhanced-resolve@5.19.0: - resolution: {integrity: sha512-phv3E1Xl4tQOShqSte26C7Fl84EwUdZsyOuSSk9qtAGyyQs2s3jJzComh+Abf4g187lUUAvH+H26omrqia2aGg==} - engines: {node: '>=10.13.0'} - enquirer@2.4.1: resolution: {integrity: sha512-rRqJg/6gd538VHvR3PSrdRBb/1Vy2YfzHqzvbhGIQpDRKIa4FgV/54b5Q1xYSxOOwKvjXweS26E0Q+nAMwp2pQ==} engines: {node: '>=8.6'} @@ -1841,9 +1518,6 @@ packages: resolution: {integrity: sha512-eTIzlVOSUR+JxdDFepEYcBMtZ9Qqdef+rnzWdRZuMbOywu5tO2w2N7rqjoANZ5k9vywhL6Br1VRjUIgTQx4E8w==} engines: {node: '>=6'} - kubernetes-types@1.30.0: - resolution: {integrity: sha512-Dew1okvhM/SQcIa2rcgujNndZwU8VnSapDgdxlYoB84ZlpAD43U6KLAFqYo17ykSFGHNPrg0qry0bP+GJd9v7Q==} - levn@0.4.1: resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==} engines: {node: '>= 0.8.0'} @@ -1994,11 +1668,6 @@ packages: resolution: {integrity: sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==} engines: {node: '>=8.6'} - mime@3.0.0: - resolution: {integrity: sha512-jSCU7/VB1loIWBZe14aEYHU/+1UMEHoaO7qxCOVJOw9GgH72VAWppxNcjU+x9a2k3GSIBXNKxXQFqRvvZ7vr3A==} - engines: {node: '>=10.0.0'} - hasBin: true - mimic-function@5.0.1: resolution: {integrity: sha512-VP79XUPxV2CigYP3jWwAUFSku2aKqBH7uTAapFWCBqutsbmDo96KY5o8uh6U+/YSIn5OxJnXp73beVkpqMIGhA==} engines: {node: '>=18'} @@ -2049,9 +1718,6 @@ packages: natural-compare@1.4.0: resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==} - node-addon-api@7.1.1: - resolution: {integrity: sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==} - node-gyp-build-optional-packages@5.2.2: resolution: {integrity: sha512-s+w+rBWnpTMwSFbaE0UXsRlg7hU4FjekKU4eyAih5T8nJuNZT1nNsskXpxmeqSK9UzkBl6UgRlnKc8hz8IEqOw==} hasBin: true @@ -2381,13 +2047,6 @@ packages: resolution: {integrity: sha512-MeQTA1r0litLUf0Rp/iisCaL8761lKAZHaimlbGK4j0HysC4PLfqygQj9srcs0m2RdtDYnF8UuYyKpbjHYp7Jw==} engines: {node: ^14.18.0 || >=16.0.0} - tailwindcss@4.1.18: - resolution: {integrity: sha512-4+Z+0yiYyEtUVCScyfHCxOYP06L5Ne+JiHhY2IjR2KWMIWhJOYZKLSGZaP5HkZ8+bY0cxfzwDE5uOmzFXyIwxw==} - - tapable@2.3.0: - resolution: {integrity: sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg==} - engines: {node: '>=6'} - term-size@2.2.1: resolution: {integrity: sha512-wK0Ri4fOGjv/XPy8SBHZChl8CM7uMc5VML7SqiQ0zG7+J5Vr+RMQDoHa2CNT6KHUnTGIXH34UDMkPzAUyapBZg==} engines: {node: '>=8'} @@ -2515,10 +2174,6 @@ packages: undici-types@7.16.0: resolution: {integrity: sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==} - undici@7.22.0: - resolution: {integrity: sha512-RqslV2Us5BrllB+JeiZnK4peryVTndy9Dnqq62S3yYRRTj0tFQCwEniUy2167skdGOy3vqRzEvl1Dm4sV2ReDg==} - engines: {node: '>=20.18.1'} - universalify@0.1.2: resolution: {integrity: sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg==} engines: {node: '>= 4.0.0'} @@ -2526,10 +2181,6 @@ packages: uri-js@4.4.1: resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==} - uuid@11.1.0: - resolution: {integrity: sha512-0/A9rDy9P7cJ+8w1c9WD9V//9Wj15Ce2MPz8Ri6032usz+NfePxx5AcN3bN+r6ZL6jEo066/yNYB3tn4pQEx+A==} - hasBin: true - vite@7.3.0: resolution: {integrity: sha512-dZwN5L1VlUBewiP6H9s2+B3e3Jg96D0vzN+Ry73sOefebhYr9f94wwkMNN/9ouoU8pV1BqA1d1zGk8928cx0rg==} engines: {node: ^20.19.0 || >=22.12.0} @@ -2918,50 +2569,6 @@ snapshots: '@csstools/css-tokenizer@3.0.4': {} - '@effect/cluster@0.56.4(@effect/platform@0.94.0(effect@3.19.13))(@effect/rpc@0.73.2(@effect/platform@0.94.0(effect@3.19.13))(effect@3.19.13))(@effect/sql@0.49.0(@effect/experimental@0.58.0(@effect/platform@0.94.0(effect@3.19.13))(effect@3.19.13))(@effect/platform@0.94.0(effect@3.19.13))(effect@3.19.13))(@effect/workflow@0.16.0(@effect/experimental@0.58.0(@effect/platform@0.94.0(effect@3.19.13))(effect@3.19.13))(@effect/platform@0.94.0(effect@3.19.13))(@effect/rpc@0.73.2(@effect/platform@0.94.0(effect@3.19.13))(effect@3.19.13))(effect@3.19.13))(effect@3.19.13)': - dependencies: - '@effect/platform': 0.94.0(effect@3.19.13) - '@effect/rpc': 0.73.2(@effect/platform@0.94.0(effect@3.19.13))(effect@3.19.13) - '@effect/sql': 0.49.0(@effect/experimental@0.58.0(@effect/platform@0.94.0(effect@3.19.13))(effect@3.19.13))(@effect/platform@0.94.0(effect@3.19.13))(effect@3.19.13) - '@effect/workflow': 0.16.0(@effect/experimental@0.58.0(@effect/platform@0.94.0(effect@3.19.13))(effect@3.19.13))(@effect/platform@0.94.0(effect@3.19.13))(@effect/rpc@0.73.2(@effect/platform@0.94.0(effect@3.19.13))(effect@3.19.13))(effect@3.19.13) - effect: 3.19.13 - kubernetes-types: 1.30.0 - - '@effect/experimental@0.58.0(@effect/platform@0.94.0(effect@3.19.13))(effect@3.19.13)': - dependencies: - '@effect/platform': 0.94.0(effect@3.19.13) - effect: 3.19.13 - uuid: 11.1.0 - - '@effect/platform-node-shared@0.57.1(@effect/cluster@0.56.4(@effect/platform@0.94.0(effect@3.19.13))(@effect/rpc@0.73.2(@effect/platform@0.94.0(effect@3.19.13))(effect@3.19.13))(@effect/sql@0.49.0(@effect/experimental@0.58.0(@effect/platform@0.94.0(effect@3.19.13))(effect@3.19.13))(@effect/platform@0.94.0(effect@3.19.13))(effect@3.19.13))(@effect/workflow@0.16.0(@effect/experimental@0.58.0(@effect/platform@0.94.0(effect@3.19.13))(effect@3.19.13))(@effect/platform@0.94.0(effect@3.19.13))(@effect/rpc@0.73.2(@effect/platform@0.94.0(effect@3.19.13))(effect@3.19.13))(effect@3.19.13))(effect@3.19.13))(@effect/platform@0.94.0(effect@3.19.13))(@effect/rpc@0.73.2(@effect/platform@0.94.0(effect@3.19.13))(effect@3.19.13))(@effect/sql@0.49.0(@effect/experimental@0.58.0(@effect/platform@0.94.0(effect@3.19.13))(effect@3.19.13))(@effect/platform@0.94.0(effect@3.19.13))(effect@3.19.13))(effect@3.19.13)': - dependencies: - '@effect/cluster': 0.56.4(@effect/platform@0.94.0(effect@3.19.13))(@effect/rpc@0.73.2(@effect/platform@0.94.0(effect@3.19.13))(effect@3.19.13))(@effect/sql@0.49.0(@effect/experimental@0.58.0(@effect/platform@0.94.0(effect@3.19.13))(effect@3.19.13))(@effect/platform@0.94.0(effect@3.19.13))(effect@3.19.13))(@effect/workflow@0.16.0(@effect/experimental@0.58.0(@effect/platform@0.94.0(effect@3.19.13))(effect@3.19.13))(@effect/platform@0.94.0(effect@3.19.13))(@effect/rpc@0.73.2(@effect/platform@0.94.0(effect@3.19.13))(effect@3.19.13))(effect@3.19.13))(effect@3.19.13) - '@effect/platform': 0.94.0(effect@3.19.13) - '@effect/rpc': 0.73.2(@effect/platform@0.94.0(effect@3.19.13))(effect@3.19.13) - '@effect/sql': 0.49.0(@effect/experimental@0.58.0(@effect/platform@0.94.0(effect@3.19.13))(effect@3.19.13))(@effect/platform@0.94.0(effect@3.19.13))(effect@3.19.13) - '@parcel/watcher': 2.5.6 - effect: 3.19.13 - multipasta: 0.2.7 - ws: 8.18.3 - transitivePeerDependencies: - - bufferutil - - utf-8-validate - - '@effect/platform-node@0.104.1(@effect/cluster@0.56.4(@effect/platform@0.94.0(effect@3.19.13))(@effect/rpc@0.73.2(@effect/platform@0.94.0(effect@3.19.13))(effect@3.19.13))(@effect/sql@0.49.0(@effect/experimental@0.58.0(@effect/platform@0.94.0(effect@3.19.13))(effect@3.19.13))(@effect/platform@0.94.0(effect@3.19.13))(effect@3.19.13))(@effect/workflow@0.16.0(@effect/experimental@0.58.0(@effect/platform@0.94.0(effect@3.19.13))(effect@3.19.13))(@effect/platform@0.94.0(effect@3.19.13))(@effect/rpc@0.73.2(@effect/platform@0.94.0(effect@3.19.13))(effect@3.19.13))(effect@3.19.13))(effect@3.19.13))(@effect/platform@0.94.0(effect@3.19.13))(@effect/rpc@0.73.2(@effect/platform@0.94.0(effect@3.19.13))(effect@3.19.13))(@effect/sql@0.49.0(@effect/experimental@0.58.0(@effect/platform@0.94.0(effect@3.19.13))(effect@3.19.13))(@effect/platform@0.94.0(effect@3.19.13))(effect@3.19.13))(effect@3.19.13)': - dependencies: - '@effect/cluster': 0.56.4(@effect/platform@0.94.0(effect@3.19.13))(@effect/rpc@0.73.2(@effect/platform@0.94.0(effect@3.19.13))(effect@3.19.13))(@effect/sql@0.49.0(@effect/experimental@0.58.0(@effect/platform@0.94.0(effect@3.19.13))(effect@3.19.13))(@effect/platform@0.94.0(effect@3.19.13))(effect@3.19.13))(@effect/workflow@0.16.0(@effect/experimental@0.58.0(@effect/platform@0.94.0(effect@3.19.13))(effect@3.19.13))(@effect/platform@0.94.0(effect@3.19.13))(@effect/rpc@0.73.2(@effect/platform@0.94.0(effect@3.19.13))(effect@3.19.13))(effect@3.19.13))(effect@3.19.13) - '@effect/platform': 0.94.0(effect@3.19.13) - '@effect/platform-node-shared': 0.57.1(@effect/cluster@0.56.4(@effect/platform@0.94.0(effect@3.19.13))(@effect/rpc@0.73.2(@effect/platform@0.94.0(effect@3.19.13))(effect@3.19.13))(@effect/sql@0.49.0(@effect/experimental@0.58.0(@effect/platform@0.94.0(effect@3.19.13))(effect@3.19.13))(@effect/platform@0.94.0(effect@3.19.13))(effect@3.19.13))(@effect/workflow@0.16.0(@effect/experimental@0.58.0(@effect/platform@0.94.0(effect@3.19.13))(effect@3.19.13))(@effect/platform@0.94.0(effect@3.19.13))(@effect/rpc@0.73.2(@effect/platform@0.94.0(effect@3.19.13))(effect@3.19.13))(effect@3.19.13))(effect@3.19.13))(@effect/platform@0.94.0(effect@3.19.13))(@effect/rpc@0.73.2(@effect/platform@0.94.0(effect@3.19.13))(effect@3.19.13))(@effect/sql@0.49.0(@effect/experimental@0.58.0(@effect/platform@0.94.0(effect@3.19.13))(effect@3.19.13))(@effect/platform@0.94.0(effect@3.19.13))(effect@3.19.13))(effect@3.19.13) - '@effect/rpc': 0.73.2(@effect/platform@0.94.0(effect@3.19.13))(effect@3.19.13) - '@effect/sql': 0.49.0(@effect/experimental@0.58.0(@effect/platform@0.94.0(effect@3.19.13))(effect@3.19.13))(@effect/platform@0.94.0(effect@3.19.13))(effect@3.19.13) - effect: 3.19.13 - mime: 3.0.0 - undici: 7.22.0 - ws: 8.18.3 - transitivePeerDependencies: - - bufferutil - - utf-8-validate - '@effect/platform@0.94.0(effect@3.19.13)': dependencies: effect: 3.19.13 @@ -2969,31 +2576,11 @@ snapshots: msgpackr: 1.11.8 multipasta: 0.2.7 - '@effect/rpc@0.73.2(@effect/platform@0.94.0(effect@3.19.13))(effect@3.19.13)': - dependencies: - '@effect/platform': 0.94.0(effect@3.19.13) - effect: 3.19.13 - msgpackr: 1.11.8 - - '@effect/sql@0.49.0(@effect/experimental@0.58.0(@effect/platform@0.94.0(effect@3.19.13))(effect@3.19.13))(@effect/platform@0.94.0(effect@3.19.13))(effect@3.19.13)': - dependencies: - '@effect/experimental': 0.58.0(@effect/platform@0.94.0(effect@3.19.13))(effect@3.19.13) - '@effect/platform': 0.94.0(effect@3.19.13) - effect: 3.19.13 - uuid: 11.1.0 - '@effect/vitest@0.27.0(effect@3.19.19)(vitest@4.0.16)': dependencies: effect: 3.19.19 vitest: 4.0.16(@types/node@24.10.4)(@vitest/browser-playwright@4.0.16)(jiti@2.6.1)(jsdom@27.3.0)(lightningcss@1.30.2)(tsx@4.21.0)(yaml@2.8.2) - '@effect/workflow@0.16.0(@effect/experimental@0.58.0(@effect/platform@0.94.0(effect@3.19.13))(effect@3.19.13))(@effect/platform@0.94.0(effect@3.19.13))(@effect/rpc@0.73.2(@effect/platform@0.94.0(effect@3.19.13))(effect@3.19.13))(effect@3.19.13)': - dependencies: - '@effect/experimental': 0.58.0(@effect/platform@0.94.0(effect@3.19.13))(effect@3.19.13) - '@effect/platform': 0.94.0(effect@3.19.13) - '@effect/rpc': 0.73.2(@effect/platform@0.94.0(effect@3.19.13))(effect@3.19.13) - effect: 3.19.13 - '@esbuild/aix-ppc64@0.27.2': optional: true @@ -3169,11 +2756,6 @@ snapshots: '@jridgewell/sourcemap-codec': 1.5.5 '@jridgewell/trace-mapping': 0.3.31 - '@jridgewell/remapping@2.3.5': - dependencies: - '@jridgewell/gen-mapping': 0.3.13 - '@jridgewell/trace-mapping': 0.3.31 - '@jridgewell/resolve-uri@3.1.2': {} '@jridgewell/sourcemap-codec@1.5.5': {} @@ -3229,66 +2811,6 @@ snapshots: '@nodelib/fs.scandir': 2.1.5 fastq: 1.20.1 - '@parcel/watcher-android-arm64@2.5.6': - optional: true - - '@parcel/watcher-darwin-arm64@2.5.6': - optional: true - - '@parcel/watcher-darwin-x64@2.5.6': - optional: true - - '@parcel/watcher-freebsd-x64@2.5.6': - optional: true - - '@parcel/watcher-linux-arm-glibc@2.5.6': - optional: true - - '@parcel/watcher-linux-arm-musl@2.5.6': - optional: true - - '@parcel/watcher-linux-arm64-glibc@2.5.6': - optional: true - - '@parcel/watcher-linux-arm64-musl@2.5.6': - optional: true - - '@parcel/watcher-linux-x64-glibc@2.5.6': - optional: true - - '@parcel/watcher-linux-x64-musl@2.5.6': - optional: true - - '@parcel/watcher-win32-arm64@2.5.6': - optional: true - - '@parcel/watcher-win32-ia32@2.5.6': - optional: true - - '@parcel/watcher-win32-x64@2.5.6': - optional: true - - '@parcel/watcher@2.5.6': - dependencies: - detect-libc: 2.1.2 - is-glob: 4.0.3 - node-addon-api: 7.1.1 - picomatch: 4.0.3 - optionalDependencies: - '@parcel/watcher-android-arm64': 2.5.6 - '@parcel/watcher-darwin-arm64': 2.5.6 - '@parcel/watcher-darwin-x64': 2.5.6 - '@parcel/watcher-freebsd-x64': 2.5.6 - '@parcel/watcher-linux-arm-glibc': 2.5.6 - '@parcel/watcher-linux-arm-musl': 2.5.6 - '@parcel/watcher-linux-arm64-glibc': 2.5.6 - '@parcel/watcher-linux-arm64-musl': 2.5.6 - '@parcel/watcher-linux-x64-glibc': 2.5.6 - '@parcel/watcher-linux-x64-musl': 2.5.6 - '@parcel/watcher-win32-arm64': 2.5.6 - '@parcel/watcher-win32-ia32': 2.5.6 - '@parcel/watcher-win32-x64': 2.5.6 - '@pkgr/core@0.2.9': {} '@polka/url@1.0.0-next.29': {} @@ -3381,74 +2903,6 @@ snapshots: '@standard-schema/spec@1.1.0': {} - '@tailwindcss/node@4.1.18': - dependencies: - '@jridgewell/remapping': 2.3.5 - enhanced-resolve: 5.19.0 - jiti: 2.6.1 - lightningcss: 1.30.2 - magic-string: 0.30.21 - source-map-js: 1.2.1 - tailwindcss: 4.1.18 - - '@tailwindcss/oxide-android-arm64@4.1.18': - optional: true - - '@tailwindcss/oxide-darwin-arm64@4.1.18': - optional: true - - '@tailwindcss/oxide-darwin-x64@4.1.18': - optional: true - - '@tailwindcss/oxide-freebsd-x64@4.1.18': - optional: true - - '@tailwindcss/oxide-linux-arm-gnueabihf@4.1.18': - optional: true - - '@tailwindcss/oxide-linux-arm64-gnu@4.1.18': - optional: true - - '@tailwindcss/oxide-linux-arm64-musl@4.1.18': - optional: true - - '@tailwindcss/oxide-linux-x64-gnu@4.1.18': - optional: true - - '@tailwindcss/oxide-linux-x64-musl@4.1.18': - optional: true - - '@tailwindcss/oxide-wasm32-wasi@4.1.18': - optional: true - - '@tailwindcss/oxide-win32-arm64-msvc@4.1.18': - optional: true - - '@tailwindcss/oxide-win32-x64-msvc@4.1.18': - optional: true - - '@tailwindcss/oxide@4.1.18': - optionalDependencies: - '@tailwindcss/oxide-android-arm64': 4.1.18 - '@tailwindcss/oxide-darwin-arm64': 4.1.18 - '@tailwindcss/oxide-darwin-x64': 4.1.18 - '@tailwindcss/oxide-freebsd-x64': 4.1.18 - '@tailwindcss/oxide-linux-arm-gnueabihf': 4.1.18 - '@tailwindcss/oxide-linux-arm64-gnu': 4.1.18 - '@tailwindcss/oxide-linux-arm64-musl': 4.1.18 - '@tailwindcss/oxide-linux-x64-gnu': 4.1.18 - '@tailwindcss/oxide-linux-x64-musl': 4.1.18 - '@tailwindcss/oxide-wasm32-wasi': 4.1.18 - '@tailwindcss/oxide-win32-arm64-msvc': 4.1.18 - '@tailwindcss/oxide-win32-x64-msvc': 4.1.18 - - '@tailwindcss/vite@4.1.18(vite@7.3.0(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)(yaml@2.8.2))': - dependencies: - '@tailwindcss/node': 4.1.18 - '@tailwindcss/oxide': 4.1.18 - tailwindcss: 4.1.18 - vite: 7.3.0(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)(yaml@2.8.2) - '@types/chai@5.2.3': dependencies: '@types/deep-eql': 4.0.2 @@ -3464,6 +2918,15 @@ snapshots: '@types/json-schema@7.0.15': {} + '@types/linkify-it@5.0.0': {} + + '@types/markdown-it@14.1.2': + dependencies: + '@types/linkify-it': 5.0.0 + '@types/mdurl': 2.0.0 + + '@types/mdurl@2.0.0': {} + '@types/node@12.20.55': {} '@types/node@24.10.4': @@ -3796,8 +3259,6 @@ snapshots: '@csstools/css-syntax-patches-for-csstree': 1.0.22 css-tree: 3.1.0 - daisyui@5.5.18: {} - data-urls@6.0.0: dependencies: whatwg-mimetype: 4.0.0 @@ -3813,7 +3274,8 @@ snapshots: detect-indent@6.1.0: {} - detect-libc@2.1.2: {} + detect-libc@2.1.2: + optional: true dir-glob@3.0.1: dependencies: @@ -3831,11 +3293,6 @@ snapshots: emoji-regex@10.6.0: {} - enhanced-resolve@5.19.0: - dependencies: - graceful-fs: 4.2.11 - tapable: 2.3.0 - enquirer@2.4.1: dependencies: ansi-colors: 4.1.3 @@ -4057,6 +3514,7 @@ snapshots: get-tsconfig@4.13.0: dependencies: resolve-pkg-maps: 1.0.0 + optional: true glob-parent@5.1.2: dependencies: @@ -4169,7 +3627,8 @@ snapshots: html-escaper: 2.0.2 istanbul-lib-report: 3.0.1 - jiti@2.6.1: {} + jiti@2.6.1: + optional: true joycon@3.1.1: {} @@ -4231,8 +3690,6 @@ snapshots: kleur@3.0.3: {} - kubernetes-types@1.30.0: {} - levn@0.4.1: dependencies: prelude-ls: 1.2.1 @@ -4286,6 +3743,7 @@ snapshots: lightningcss-linux-x64-musl: 1.30.2 lightningcss-win32-arm64-msvc: 1.30.2 lightningcss-win32-x64-msvc: 1.30.2 + optional: true lilconfig@3.1.3: {} @@ -4374,8 +3832,6 @@ snapshots: braces: 3.0.3 picomatch: 2.3.1 - mime@3.0.0: {} - mimic-function@5.0.1: {} minimatch@3.1.2: @@ -4429,8 +3885,6 @@ snapshots: natural-compare@1.4.0: {} - node-addon-api@7.1.1: {} - node-gyp-build-optional-packages@5.2.2: dependencies: detect-libc: 2.1.2 @@ -4586,7 +4040,8 @@ snapshots: resolve-from@5.0.0: {} - resolve-pkg-maps@1.0.0: {} + resolve-pkg-maps@1.0.0: + optional: true restore-cursor@5.1.0: dependencies: @@ -4722,10 +4177,6 @@ snapshots: dependencies: '@pkgr/core': 0.2.9 - tailwindcss@4.1.18: {} - - tapable@2.3.0: {} - term-size@2.2.1: {} thenify-all@1.6.0: @@ -4811,6 +4262,7 @@ snapshots: get-tsconfig: 4.13.0 optionalDependencies: fsevents: 2.3.3 + optional: true type-check@0.4.0: dependencies: @@ -4848,16 +4300,12 @@ snapshots: undici-types@7.16.0: {} - undici@7.22.0: {} - universalify@0.1.2: {} uri-js@4.4.1: dependencies: punycode: 2.3.1 - uuid@11.1.0: {} - vite@7.3.0(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)(yaml@2.8.2): dependencies: esbuild: 0.27.2 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index dee51e92..0e5a0737 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -1,2 +1,3 @@ packages: - "packages/*" + - "apps/*" diff --git a/typedoc.json b/typedoc.json index 80ad2c34..fffa5db6 100644 --- a/typedoc.json +++ b/typedoc.json @@ -6,8 +6,6 @@ "./packages/router/src/index.ts", "./packages/form/src/index.ts", "./packages/platform/src/index.ts", - "./packages/platform/src/server.ts", - "./packages/platform/src/client.ts", "./packages/vite-plugin/src/index.ts" ], "out": "./docs/api", @@ -17,6 +15,6 @@ "hideGenerator": true, "githubPages": false, "entryPointStrategy": "expand", - "exclude": ["**/*.test.ts", "**/*.stories.ts", "**/stories/**"], + "exclude": ["**/*.test.ts"], "skipErrorChecking": true } From e04c9627b19aa9dd997c210c7850e51559843ab9 Mon Sep 17 00:00:00 2001 From: Jon Laing Date: Fri, 27 Mar 2026 20:42:45 -0400 Subject: [PATCH 3/7] fixing some type nonsense for core, dom and router to support ssg --- .prettierignore | 2 +- packages/core/README.md | 4 +- packages/dom/src/Collect.ts | 4 +- packages/dom/src/Element/DOMElements.ts | 95 +-- packages/dom/src/Element/core.ts | 36 +- packages/dom/src/Element/index.ts | 1 + packages/dom/src/Element/types.ts | 35 +- packages/dom/src/Provide.ts | 7 +- packages/dom/src/index.ts | 35 +- packages/platform/src/Platform.ts | 164 +++-- packages/router/src/Navigation.ts | 102 ++- packages/router/src/Outlet.ts | 56 +- packages/router/src/Route.ts | 41 +- packages/router/src/RouteData.ts | 4 +- packages/router/src/Router.ts | 290 ++++++--- packages/router/src/index.ts | 2 + packages/vite-plugin/src/plugin.ts | 69 +- pnpm-lock.yaml | 818 ++++++++++++++++++------ tsconfig.base.json | 1 - 19 files changed, 1268 insertions(+), 498 deletions(-) diff --git a/.prettierignore b/.prettierignore index e61f83d4..8cf613d3 100644 --- a/.prettierignore +++ b/.prettierignore @@ -1,6 +1,6 @@ coverage public dist -docs +./docs pnpm-lock.yaml *.md diff --git a/packages/core/README.md b/packages/core/README.md index c097a9bf..1207bc0c 100644 --- a/packages/core/README.md +++ b/packages/core/README.md @@ -100,7 +100,7 @@ interface ButtonProps { class?: Readable.Reactive; } -const Button = (props: ButtonProps, children: ChildEffect) => { +const Button = (props: ButtonProps, child: Child) => { // Normalize props - works whether they're static or reactive const disabled = Readable.normalize(props.disabled ?? false); const className = Readable.normalize(props.class ?? ""); @@ -112,7 +112,7 @@ const Button = (props: ButtonProps, children: ChildEffect) => { return $.button( { class: className, disabled, "aria-disabled": ariaDisabled }, - children + child ); }; ``` diff --git a/packages/dom/src/Collect.ts b/packages/dom/src/Collect.ts index 6bafc598..1be6c6af 100644 --- a/packages/dom/src/Collect.ts +++ b/packages/dom/src/Collect.ts @@ -3,7 +3,7 @@ import { Effect } from "effect"; import type { ChildNode } from "./Element/types.js"; /** - * Combine multiple child effects into a single ChildEffect. + * Combine multiple child effects into a single Child. * Use this when an element needs multiple children. * * Error and context types are properly propagated through the union, @@ -20,7 +20,7 @@ import type { ChildNode } from "./Element/types.js"; * )) * * // Mixing static and effectful children - * const Card = (children: ChildEffect) => + * const Card = (children: Child) => * div({ class: "card" }, collect( * h1({}, $.of("Title")), * children // E and R propagate up diff --git a/packages/dom/src/Element/DOMElements.ts b/packages/dom/src/Element/DOMElements.ts index b0def7d7..deecd2b9 100644 --- a/packages/dom/src/Element/DOMElements.ts +++ b/packages/dom/src/Element/DOMElements.ts @@ -10,6 +10,7 @@ import { MergePropsCtx, Readable, RendererContext } from "@effex/core"; import * as Core from "./core.js"; import type { ElementRef } from "./ref.js"; +import type { Child, Element } from "./types.js"; // ============================================================================= // Types @@ -198,20 +199,20 @@ export type SVGAttributes = export type ElementFactory = { ( attrs: HTMLAttributes, - children: Core.Child, - ): Core.Element; + children: Child, + ): Element; ( attrs: HTMLAttributes, - ): Core.Element; + ): Element; ( - children: Core.Child, - ): Core.Element; - (children: string): Core.Element; - (children: number): Core.Element; + children: Child, + ): Element; + (children: string): Element; + (children: number): Element; ( children: Readable.Readable, - ): Core.Element; - (): Core.Element; + ): Element; + (): Element; }; /** @@ -220,20 +221,20 @@ export type ElementFactory = { export type SVGElementFactory = { ( attrs: SVGAttributes, - children: Core.Child, - ): Core.Element; + children: Child, + ): Element; ( attrs: SVGAttributes, - ): Core.Element; + ): Element; ( - children: Core.Child, - ): Core.Element; - (children: string): Core.Element; - (children: number): Core.Element; + children: Child, + ): Element; + (children: string): Element; + (children: number): Element; ( children: Readable.Readable, - ): Core.Element; - (): Core.Element; + ): Element; + (): Element; }; // ============================================================================= @@ -255,9 +256,9 @@ const classValueToString = (value: string | readonly string[]): string => * Apply class attribute to an element using Core functions. */ const applyClass =
    ( - el: Core.Element, + el: Element, value: ClassValue, -): Core.Element => { +): Element => { if (Readable.isReadable(value)) { // For reactive class that's a string/array, we need to handle it specially return Effect.gen(function* () { @@ -271,7 +272,7 @@ const applyClass = ( renderer.setClassName(element, classValueToString(v)), ).pipe(Effect.forkIn(scope)); return element; - }) as Core.Element; + }) as Element; } else if (typeof value === "string") { return Core.setClass(el, value); } else if (Array.isArray(value)) { @@ -315,7 +316,7 @@ const applyClass = ( yield* updateClassName(); return element; - }) as Core.Element; + }) as Element; } } @@ -326,9 +327,9 @@ const applyClass = ( * Apply style attribute to an element using Core functions. */ const applyStyle = ( - el: Core.Element, + el: Element, value: Record | Readable.Readable>, -): Core.Element => { +): Element => { if (Readable.isReadable(value)) { // Entire style object is reactive return Effect.gen(function* () { @@ -348,7 +349,7 @@ const applyStyle = ( ), ).pipe(Effect.forkIn(scope)); return element; - }) as Core.Element; + }) as Element; } // Object with potentially reactive values @@ -371,10 +372,10 @@ const applyStyle = ( * Apply an event handler to an element using Core functions. */ const applyEventHandler = ( - el: Core.Element, + el: Element, eventName: string, handler: EventHandler, -): Core.Element => +): Element => Core.on( el, eventName as keyof HTMLElementEventMap, @@ -385,9 +386,9 @@ const applyEventHandler = ( * Apply innerHTML to an element using Core functions. */ const applyInnerHTML = ( - el: Core.Element, + el: Element, value: string | Readable.Readable, -): Core.Element => { +): Element => { if (Readable.isReadable(value)) { return Core.bindInnerHTML(el, value); } @@ -420,10 +421,10 @@ const BOOLEAN_ATTRIBUTES = new Set([ * Apply a generic attribute to an element using Core functions. */ const applyAttribute = ( - el: Core.Element, + el: Element, key: string, value: unknown, -): Core.Element => { +): Element => { if (Readable.isReadable(value)) { if (BOOLEAN_ATTRIBUTES.has(key)) { return Core.bindBooleanAttribute( @@ -444,9 +445,9 @@ const applyAttribute = ( * Apply input value (special handling to avoid cursor reset). */ const applyInputValue = ( - el: Core.Element, + el: Element, value: unknown, -): Core.Element => { +): Element => { if (Readable.isReadable(value)) { return Core.bindInputValue(el, value as Readable.Readable); } @@ -457,9 +458,9 @@ const applyInputValue = ( * Apply all attributes to an element using Core functions. */ const applyAttributes = ( - el: Core.Element, + el: Element, attrs: Record, -): Core.Element => { +): Element => { let result = el; for (const [key, value] of Object.entries(attrs)) { @@ -484,10 +485,10 @@ const applyAttributes = ( } else if (key.startsWith("on") && key.length > 2) { const eventName = key.slice(2).toLowerCase(); result = applyEventHandler( - result as Core.Element, + result as Element, eventName, value as EventHandler, - ) as Core.Element; + ) as Element; } else if (key === "value") { // Check if this is an input-like element at runtime result = applyInputValue(result, value); @@ -509,8 +510,8 @@ const applyAttributes = ( const createElement = ( tagName: K, attrs: HTMLAttributes, - children: Core.Child, -): Core.Element => + children: Child, +): Element => Effect.gen(function* () { // Check for injected props from asChild pattern const mergePropsOption = yield* Effect.serviceOption(MergePropsCtx); @@ -537,7 +538,7 @@ const createElement = ( yield* renderer.finalizeNode(result); return result; - }) as Core.Element; + }) as Element; /** * Create an SVG element with attributes and children using Core functions. @@ -545,8 +546,8 @@ const createElement = ( const createSVGElement = ( tagName: K, attrs: SVGAttributes, - children: Core.Child, -): Core.Element => + children: Child, +): Element => Effect.gen(function* () { // Check for injected props from asChild pattern const mergePropsOption = yield* Effect.serviceOption(MergePropsCtx); @@ -571,7 +572,7 @@ const createSVGElement = ( yield* renderer.finalizeNode(result); return result; - }) as Core.Element; + }) as Element; // ============================================================================= // Factory Functions @@ -598,7 +599,7 @@ const makeElementFactory = ( // Effect child if (isEffect(arg)) { - return createElement(tagName, {}, arg as Core.Child); + return createElement(tagName, {}, arg as Child); } // Readable child @@ -621,7 +622,7 @@ const makeElementFactory = ( // Two arguments: attrs and children const [attrs, children] = args as [ HTMLAttributes, - Core.Child, + Child, ]; return createElement(tagName, attrs, children); }) as ElementFactory; @@ -646,7 +647,7 @@ const makeSVGElementFactory = ( } if (isEffect(arg)) { - return createSVGElement(tagName, {}, arg as Core.Child); + return createSVGElement(tagName, {}, arg as Child); } if (Readable.isReadable(arg)) { @@ -666,7 +667,7 @@ const makeSVGElementFactory = ( const [attrs, children] = args as [ SVGAttributes, - Core.Child, + Child, ]; return createSVGElement(tagName, attrs, children); }) as SVGElementFactory; diff --git a/packages/dom/src/Element/core.ts b/packages/dom/src/Element/core.ts index f8cb75df..de413908 100644 --- a/packages/dom/src/Element/core.ts +++ b/packages/dom/src/Element/core.ts @@ -3,45 +3,13 @@ * All DOM operations go through the Renderer for SSR/SSG/hydration support. */ -import { Effect, Scope, Stream } from "effect"; +import { Effect, Stream } from "effect"; import { dual } from "effect/Function"; import { Readable, RendererContext } from "@effex/core"; import { bindElementToRef, type ElementRef } from "./ref.js"; - -// ============================================================================= -// Types -// ============================================================================= - -/** - * An Element is an Effect that produces an HTML or SVG element. - * All Element operations are effectful and go through the Renderer. - */ -export type Element< - A extends HTMLElement | SVGElement = HTMLElement | SVGElement, - E = never, - R = never, -> = Effect.Effect; - -/** - * A child that can be appended to an element. - */ -export type ChildNode = - | string - | number - | Readable.Readable - | HTMLElement - | SVGElement; - -/** - * An effectful child producer. - */ -export type Child = Effect.Effect< - ChildNode | ChildNode[], - E, - R ->; +import type { Child, ChildNode, Element } from "./types.js"; // ============================================================================= // Constructors diff --git a/packages/dom/src/Element/index.ts b/packages/dom/src/Element/index.ts index 44e5d011..73a8dcb5 100644 --- a/packages/dom/src/Element/index.ts +++ b/packages/dom/src/Element/index.ts @@ -26,5 +26,6 @@ export { } from "./ref.js"; export * from "./core.js"; +export type { Child, ChildNode, Element } from "./types.js"; export { $, MergePropsCtx } from "./DOMElements.js"; diff --git a/packages/dom/src/Element/types.ts b/packages/dom/src/Element/types.ts index 0acf7f72..996ada7a 100644 --- a/packages/dom/src/Element/types.ts +++ b/packages/dom/src/Element/types.ts @@ -1,5 +1,36 @@ +import { Effect, Scope } from "effect"; + +import { Readable, RendererContext } from "@effex/core"; + +// ============================================================================= +// Types +// ============================================================================= + +/** + * An Element is an Effect that produces an HTML or SVG element. + * All Element operations are effectful and go through the Renderer. + */ +export type Element< + A extends HTMLElement | SVGElement = HTMLElement | SVGElement, + E = never, + R = never, +> = Effect.Effect; + /** - * Re-export types for backwards compatibility. + * A child that can be appended to an element. */ +export type ChildNode = + | string + | number + | Readable.Readable + | HTMLElement + | SVGElement; -export type { ChildNode, Child as ChildEffect } from "./core.js"; +/** + * An effectful child producer. + */ +export type Child = Effect.Effect< + ChildNode | ChildNode[], + E, + R +>; diff --git a/packages/dom/src/Provide.ts b/packages/dom/src/Provide.ts index aaebb190..4c58b5ab 100644 --- a/packages/dom/src/Provide.ts +++ b/packages/dom/src/Provide.ts @@ -1,6 +1,6 @@ import { Context, Effect } from "effect"; -import type { ChildEffect } from "./Element/types.js"; +import type { Child } from "./Element/types.js"; /** * Provide a context value to children elements. @@ -39,6 +39,5 @@ import type { ChildEffect } from "./Element/types.js"; export const provide = ( tag: Context.Tag, value: S, - children: ChildEffect, -): ChildEffect> => - children.pipe(Effect.provideService(tag, value)); + children: Child, +): Child> => children.pipe(Effect.provideService(tag, value)); diff --git a/packages/dom/src/index.ts b/packages/dom/src/index.ts index 5f235b08..871093e5 100644 --- a/packages/dom/src/index.ts +++ b/packages/dom/src/index.ts @@ -1,12 +1,41 @@ +// Export Element as a namespace — values come from `export * as`, +// types are added via declaration merging below to work around + +import type { Effect, Scope } from "effect"; + +import type { Readable, RendererContext } from "@effex/core"; + +import * as ElementCore from "./Element/index.js"; + +export namespace Element { + export type Child = Effect.Effect< + ChildNode | ChildNode[], + E, + R + >; + export type ChildNode = + | string + | number + | Readable.Readable + | HTMLElement + | SVGElement; + export type Element< + A extends HTMLElement | SVGElement = HTMLElement | SVGElement, + E = never, + R = never, + > = Effect.Effect; +} + +export const Element = { + ...ElementCore, +}; + // Re-export everything from core so users can import from @effex/dom export * from "@effex/core"; // DOM Renderer export { DOMRenderer, DOMRendererLive } from "./Render/DOMRenderer.js"; -// Export Element as a namespace and also re-export common types directly -export * as Element from "./Element/index.js"; - // Re-export commonly used items from Element for convenience export { $, bindElementToRef, ref } from "./Element/index.js"; diff --git a/packages/platform/src/Platform.ts b/packages/platform/src/Platform.ts index 3ee0305e..1d7f1109 100644 --- a/packages/platform/src/Platform.ts +++ b/packages/platform/src/Platform.ts @@ -21,7 +21,8 @@ import { HttpServerRequest, HttpServerResponse, } from "@effect/platform"; -import { Data, Effect, Layer, Ref, Schema, Scope } from "effect"; +import type { RouteNotFound } from "@effect/platform/HttpServerError"; +import { Data, Effect, Layer, Record, Ref, Schema, Scope } from "effect"; import { AsyncCache, @@ -30,6 +31,7 @@ import { type ControlCtx, type SuspenseBoundaryCtx, } from "@effex/core"; +import { Element } from "@effex/dom"; import { renderToString } from "@effex/dom/server"; import { Navigation, @@ -55,6 +57,8 @@ export interface DocumentOptions { readonly styles?: readonly string[]; /** Additional head content */ readonly head?: string; + /** Attributes to add to the element */ + readonly htmlAttrs?: Record; } export interface ToHttpRoutesOptions { @@ -67,8 +71,15 @@ export interface ToHttpRoutesOptions { * * If not provided, renders just the matched route with layouts. */ - readonly app?: () => import("@effex/dom").Element.Element< - HTMLElement | SVGElement + readonly app?: () => Element.Element< + HTMLElement | SVGElement, + never, + | NavigationContext + | RouteDataContext + | RendererContext + | ControlCtx + | SuspenseBoundaryCtx + | Scope.Scope >; } @@ -125,9 +136,15 @@ export const generateDocument = ( .join("\n "); const head = options?.head ?? ""; const loaderScript = generateLoaderDataScript(loaderData); + const htmlAttrs = options?.htmlAttrs + ? " " + + Object.entries(options.htmlAttrs) + .map(([k, v]) => `${k}="${v}"`) + .join(" ") + : ""; return ` - + @@ -161,8 +178,8 @@ const substituteParams = ( /** * Compute action paths for a route's handlers given current params. */ -const computeActionPaths = ( - route: RouteType, +const computeActionPaths = ( + route: RouteType, params: Record, ): Record => { const actions: Record = {}; @@ -176,27 +193,27 @@ const computeActionPaths = ( /** * Validate route params against the route's schema. */ -const validateParams = ( - route: RouteType, +const validateParams = ( + route: RouteType, rawParams: Record, -): Effect.Effect => { +): Effect.Effect => { if (route.paramsSchema) { return Schema.decodeUnknown(route.paramsSchema)(rawParams); } - return Effect.succeed(rawParams); + return Effect.succeed(rawParams as unknown as P); }; /** * Validate search params against the route's schema. */ -const validateSearchParams = ( - route: RouteType, +const validateSearchParams = ( + route: RouteType, rawSearchParams: Record, -): Effect.Effect => { +): Effect.Effect => { if (route.searchParamsSchema) { return Schema.decodeUnknown(route.searchParamsSchema)(rawSearchParams); } - return Effect.succeed(rawSearchParams); + return Effect.succeed(rawSearchParams as unknown as S); }; // Redirect handling helper — uses _tag check to work with generic E @@ -234,11 +251,16 @@ const catchRedirects = ( * * Redirect errors are caught and converted to HTTP redirect responses. */ -export const toHttpRoutes = ( - router: EffexRouter, +export const toHttpRoutes = < + P extends Record | never, + S extends Record | never, + D, + R, +>( + router: EffexRouter, options?: ToHttpRoutesOptions, ): HttpRouter.HttpRouter< - Exclude, + RedirectError | RouteNotFound, Exclude< R, | RouteDataContext @@ -255,7 +277,7 @@ export const toHttpRoutes = ( element: unknown, ) => Effect.Effect; - let httpRouter = HttpRouter.empty as HttpRouter.HttpRouter; + let httpRouter = HttpRouter.empty as HttpRouter.HttpRouter; for (const route of router.routes) { const path = route.path as `/${string}`; @@ -288,11 +310,11 @@ export const toHttpRoutes = ( ? (route._loader as (args: { params: unknown; searchParams: unknown; - }) => Effect.Effect) - : (route as any)._staticConfig?.load - ? ((route as any)._staticConfig.load as (args: { + }) => Effect.Effect) + : route._staticConfig?.load + ? (route._staticConfig.load as (args: { params: unknown; - }) => Effect.Effect) + }) => Effect.Effect) : null; if (loaderFn) { @@ -334,9 +356,11 @@ export const toHttpRoutes = ( } // Continue with loader data below - var loaderData: unknown = loaderOrRedirect.data; + // eslint-disable-next-line + var loaderData = loaderOrRedirect.data; } else { - var loaderData: unknown = undefined; + // eslint-disable-next-line + var loaderData = undefined as D; } // Compute action paths @@ -375,7 +399,7 @@ export const toHttpRoutes = ( // SSR: Render the component with data provided // Navigation layer for this request - const navLayer = Navigation.makeLayer(router as EffexRouter, { + const navLayer = Navigation.makeLayer(router, { initialPath: url.pathname, initialSearch: url.search, }); @@ -404,14 +428,14 @@ export const toHttpRoutes = ( // No app component — render just the matched route with layouts const element = route.render(loaderData).pipe( Effect.provideService(route.Params, { - params: rawRouteParams as Record, - searchParams: rawSearchParams, + params: rawRouteParams as P, + searchParams: rawSearchParams as S, }), Effect.provideService(RouteDataContext, routeData), ); - const withLayouts = (router.layouts as any[]).reduce( - (inner: any, wrapper: any) => wrapper(inner), + const withLayouts = router.layouts.reduce( + (inner, wrapper) => wrapper(inner), element, ); @@ -442,7 +466,12 @@ export const toHttpRoutes = ( ); }), ); - httpRouter = httpRouter.pipe(HttpRouter.get(path, debugHandler as any)); + httpRouter = httpRouter.pipe( + HttpRouter.get( + path, + debugHandler as unknown as HttpRouter.HttpRouter, + ), + ); // ------------------------------------------------------------------- // Mutation handlers: POST/PUT/DELETE — direct execution, no render @@ -503,18 +532,45 @@ export const toHttpRoutes = ( }); // Register on the appropriate HTTP method - const wrappedHandler = catchRedirects(mutationHandler) as any; + const wrappedHandler = catchRedirects(mutationHandler); if (handler.method === "post") { - httpRouter = httpRouter.pipe(HttpRouter.post(path, wrappedHandler)); + httpRouter = httpRouter.pipe( + HttpRouter.post( + path, + wrappedHandler as unknown as HttpRouter.HttpRouter, + ), + ); } else if (handler.method === "put") { - httpRouter = httpRouter.pipe(HttpRouter.put(path, wrappedHandler)); + httpRouter = httpRouter.pipe( + HttpRouter.put( + path, + wrappedHandler as unknown as HttpRouter.HttpRouter, + ), + ); } else if (handler.method === "delete") { - httpRouter = httpRouter.pipe(HttpRouter.del(path, wrappedHandler)); + httpRouter = httpRouter.pipe( + HttpRouter.del( + path, + wrappedHandler as unknown as HttpRouter.HttpRouter, + ), + ); } } } - return httpRouter; + return httpRouter as unknown as HttpRouter.HttpRouter< + RedirectError | RouteNotFound, + Exclude< + R, + | RouteDataContext + | RouteDataProvider + | NavigationContext + | RendererContext + | ControlCtx + | SuspenseBoundaryCtx + | Scope.Scope + > + >; }; // ============================================================================= @@ -547,8 +603,14 @@ declare const window: Window & { * Effect.runPromise(Effect.scoped(program)) * ``` */ -export const makeClientLayer = ( - router: EffexRouter, +export const makeClientLayer = < + P extends Record | never, + S extends Record | never, + D, + E, + R, +>( + router: EffexRouter, ): Layer.Layer => { const dataProviderLayer = Layer.scoped( RouteDataProvider, @@ -606,13 +668,16 @@ export const makeClientLayer = ( export interface BuildStaticSiteOptions { /** The router containing routes to build */ - readonly router: EffexRouter; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + readonly router: EffexRouter; /** * Root app component. If provided, each page renders through this * (same component tree the client would hydrate). */ readonly app?: () => import("@effex/dom").Element.Element< - HTMLElement | SVGElement + HTMLElement | SVGElement, + never, + never >; /** Document generation options (title, scripts, styles) */ readonly document?: DocumentOptions; @@ -669,7 +734,7 @@ export const buildStaticSite = ( }> = []; for (const route of router.routes) { - const staticConfig = (route as any)._staticConfig; + const staticConfig = route._staticConfig; if (!staticConfig) continue; // Get all param sets for this route @@ -684,7 +749,7 @@ export const buildStaticSite = ( ); pages.push({ url, - route: route as RouteType< + route: route as unknown as RouteType< string, unknown, unknown, @@ -702,11 +767,11 @@ export const buildStaticSite = ( pages, (page) => Effect.gen(function* () { - const staticConfig = (page.route as any)._staticConfig; + const staticConfig = page.route._staticConfig; // Run the loader const data = yield* ( - staticConfig.load as (args: { + staticConfig?.load as (args: { params: unknown; }) => Effect.Effect )({ @@ -721,13 +786,10 @@ export const buildStaticSite = ( }; // Navigation layer for this page - const navLayer = Navigation.makeLayer( - router as EffexRouter, - { - initialPath: page.url, - initialSearch: "", - }, - ); + const navLayer = Navigation.makeLayer(router, { + initialPath: page.url, + initialSearch: "", + }); // RouteDataProvider that returns pre-computed data const routeDataProviderLayer = Layer.succeed(RouteDataProvider, { @@ -770,7 +832,7 @@ export const buildStaticSite = ( // Render 404 page from router fallback if (router.fallback) { - const navLayer = Navigation.makeLayer(router as EffexRouter, { + const navLayer = Navigation.makeLayer(router, { initialPath: "/404", initialSearch: "", }); diff --git a/packages/router/src/Navigation.ts b/packages/router/src/Navigation.ts index 3c03b547..1d917e19 100644 --- a/packages/router/src/Navigation.ts +++ b/packages/router/src/Navigation.ts @@ -1,4 +1,4 @@ -import { Context, Effect, Layer, Option, Scope } from "effect"; +import { Context, Effect, Layer, Option, Record, Scope } from "effect"; import { Readable, Signal } from "@effex/core"; @@ -13,8 +13,8 @@ import { findMatch, type Router } from "./Router.js"; * Build a path string from a route and params. * Replaces :param segments with actual values and appends search params. */ -export const buildPath = ( - route: Route, +export const buildPath = ( + route: Route, params: P, searchParams?: SP, ): string => { @@ -64,17 +64,17 @@ export interface RouteNavigateOptions { /** * The current match result from the router. */ -export interface CurrentMatch { - readonly route: Route; +export interface CurrentMatch { + readonly route: Route; readonly params: Record; } /** * Navigation service for managing browser history and route state. */ -export interface Navigation { +export interface Navigation { /** The router this navigation is bound to */ - readonly router: Router; + readonly router: Router; /** Current pathname as a reactive readable */ readonly pathname: Readable.Readable; @@ -83,7 +83,7 @@ export interface Navigation { readonly searchParams: Readable.Readable; /** Current matched route (if any) */ - readonly currentMatch: Readable.Readable>; + readonly currentMatch: Readable.Readable; /** Navigate to a path string */ readonly pushPath: (path: string) => Effect.Effect; @@ -93,13 +93,13 @@ export interface Navigation { /** Navigate to a route with type-safe params */ readonly pushRoute: ( - route: Route, + route: Route, options?: RouteNavigateOptions, ) => Effect.Effect; /** Navigate to a route, replacing current history entry */ readonly replaceRoute: ( - route: Route, + route: Route, options?: RouteNavigateOptions, ) => Effect.Effect; @@ -119,7 +119,7 @@ export interface Navigation { */ export class NavigationContext extends Context.Tag("@effex/router/Navigation")< NavigationContext, - Navigation + Navigation >() {} // ============================================================================= @@ -152,10 +152,16 @@ export interface NavigationOptions { * }).pipe(Effect.provide(NavigationLive)); * ``` */ -export const make = ( - router: Router, +export const make = < + P extends Record | never, + S extends Record | never, + D, + E, + R, +>( + router: Router, options?: NavigationOptions, -): Effect.Effect, never, Scope.Scope> => +): Effect.Effect => Effect.gen(function* () { // Determine if we're in a browser environment const isBrowser = typeof window !== "undefined"; @@ -173,13 +179,13 @@ export const make = ( ); // Compute current match from pathname (derived readable) - const currentMatch: Readable.Readable> = Readable.map( + const currentMatch: Readable.Readable = Readable.map( pathnameState, - (pathname): CurrentMatch => { + (pathname): CurrentMatch => { const match = findMatch(router, pathname); return Option.getOrElse(match, () => ({ route: router.fallback, - })) as CurrentMatch; + })) as CurrentMatch; }, ); @@ -209,7 +215,7 @@ export const make = ( }); const pushRoute = ( - route: Route, + route: Route, opts?: RouteNavigateOptions, ): Effect.Effect => { const path = buildPath( @@ -221,7 +227,7 @@ export const make = ( }; const replaceRoute = ( - route: Route, + route: Route, opts?: RouteNavigateOptions, ): Effect.Effect => { const path = buildPath( @@ -275,24 +281,22 @@ export const make = ( replaceRoute, back, forward, - } satisfies Navigation; + } as Navigation; }); /** * Create a Layer that provides Navigation for a router. */ -export const makeLayer = ( - router: Router, +export const makeLayer = < + P extends Record | never, + S extends Record | never, + D, + E, + R, +>( + router: Router, options?: NavigationOptions, -): Layer.Layer => - Layer.scoped( - NavigationContext, - make(router, options) as Effect.Effect< - Navigation, - never, - Scope.Scope - >, - ); +) => Layer.scoped(NavigationContext, make(router, options)); // ============================================================================= // Accessor Effects @@ -317,7 +321,7 @@ export const searchParams: Effect.Effect< * Get the current matched route. */ export const currentMatch: Effect.Effect< - CurrentMatch, + CurrentMatch, never, NavigationContext > = Effect.flatMap(NavigationContext, (nav) => nav.currentMatch.get); @@ -338,6 +342,38 @@ export const replacePath = ( ): Effect.Effect => Effect.flatMap(NavigationContext, (nav) => nav.replacePath(path)); +/** + * Navigate to a route with type-safe params. + */ +export const pushRoute = ( + route: Route, + options?: RouteNavigateOptions, +): Effect.Effect => + Effect.flatMap(NavigationContext, (nav) => { + const path = buildPath( + route, + options?.params ?? ({} as P), + options?.searchParams, + ); + return nav.pushPath(path); + }); + +/** + * Navigate to a route, replacing current history entry, with type-safe params. + */ +export const replaceRoute = ( + route: Route, + options?: RouteNavigateOptions, +): Effect.Effect => + Effect.flatMap(NavigationContext, (nav) => { + const path = buildPath( + route, + options?.params ?? ({} as P), + options?.searchParams, + ); + return nav.replacePath(path); + }); + /** * Go back in history. */ @@ -364,6 +400,8 @@ export const Navigation = { currentMatch, pushPath, replacePath, + pushRoute, + replaceRoute, back, forward, }; diff --git a/packages/router/src/Outlet.ts b/packages/router/src/Outlet.ts index c7a678df..3ea03172 100644 --- a/packages/router/src/Outlet.ts +++ b/packages/router/src/Outlet.ts @@ -1,7 +1,7 @@ -import { Effect, Option } from "effect"; +import { Effect, Option, Record } from "effect"; import { ControlCtx, reconcile } from "@effex/core"; -import { $, type AnimationOptions, type Element } from "@effex/dom"; +import { $, Element, type AnimationOptions } from "@effex/dom"; import { buildPath, NavigationContext, type Navigation } from "./Navigation.js"; import type { Route } from "./Route.js"; @@ -15,9 +15,15 @@ import { findMatch, type LayoutWrapper, type Router } from "./Router.js"; /** * Configuration for the Outlet component. */ -export interface OutletConfig { +export interface OutletConfig< + P extends Record | never, + S extends Record | never, + D, + E, + R, +> { /** The router whose routes/layouts to render */ - readonly router: Router; + readonly router: Router; /** Animation options for route transitions */ readonly animate?: AnimationOptions; } @@ -44,8 +50,8 @@ const applyLayouts = ( * Check if a route's guard allows rendering. * Returns true if allowed, false if blocked. */ -const checkGuard = ( - route: Route, +const checkGuard = ( + route: Route, ): Effect.Effect => { if (!route.guard) return Effect.succeed(true); @@ -59,11 +65,11 @@ const checkGuard = ( /** * Render a route, handling guards, data loading, and layouts. */ -const renderRouteWithGuard = ( - route: Route, - nav: Navigation, +const renderRouteWithGuard = ( + route: Route, + nav: Navigation, layouts: ReadonlyArray, -): Element.Element => +): Element.Element => Effect.gen(function* () { // Check guard if present const allowed = yield* checkGuard(route); @@ -126,7 +132,7 @@ const renderRouteWithGuard = ( if (hasHooks) { // Default: run the loader directly, compute action paths const data = route._loader - ? yield* route._loader({ + ? yield* route._loader({ params: currentMatch.params, searchParams: searchParamsObj, }) @@ -190,33 +196,39 @@ const renderRouteWithGuard = ( * ) * ``` */ -export const Outlet = ( - config: OutletConfig, +export const Outlet = < + P extends Record | never, + S extends Record | never, + D, + E, + R, +>( + config: OutletConfig, ): Element.Element< HTMLElement | SVGElement, - ER | EN | EL, - RR | RN | RL | NavigationContext | ControlCtx + E, + R | NavigationContext | ControlCtx > => Effect.gen(function* () { - const nav = (yield* NavigationContext) as Navigation; + const nav = yield* NavigationContext; const router = config.router; const layouts = router.layouts; // Use pathname as the reconcile key so param-only navigations // (e.g. /users/alice → /users/bob) trigger a re-render. return (yield* reconcile(nav.pathname, { - getTargetKeys: (pathname) => { - const matched = findMatch(router as Router, pathname); + getTargetKeys: (pathname: string) => { + const matched = findMatch(router, pathname); if (Option.isSome(matched)) return [pathname]; if (router.fallback) return ["__fallback__"]; return []; }, - renderSlot: (key) => { + renderSlot: (key: string) => { if (key === "__fallback__") { return router.fallback?.() ?? $.div(); } // Find the route that matches this pathname - const matched = findMatch(router as Router, key); + const matched = findMatch(router, key); if (Option.isNone(matched)) { return router.fallback?.() ?? $.div(); } @@ -225,6 +237,6 @@ export const Outlet = ( })) as HTMLElement | SVGElement; }) as Element.Element< HTMLElement | SVGElement, - ER | EN | EL, - RR | RN | RL | NavigationContext | ControlCtx + E, + R | NavigationContext | ControlCtx >; diff --git a/packages/router/src/Route.ts b/packages/router/src/Route.ts index d6fb62fa..611feff8 100644 --- a/packages/router/src/Route.ts +++ b/packages/router/src/Route.ts @@ -34,6 +34,31 @@ export type PathSegment = | { readonly type: "param"; readonly name: string } | { readonly type: "catchAll" }; +export type ExtractRouteParams = + R extends Route + ? P + : never; + +export type ExtractRouteSearchParams = + R extends Route + ? SP + : never; + +export type ExtractRouteData = + R extends Route + ? D + : never; + +export type ExtractRouteError = + R extends Route + ? E + : never; + +export type ExtractRouteRequirements = + R extends Route + ? Rq + : never; + /** * Parse a path pattern into segments. * Handles static segments, :param segments, and * catch-all. @@ -155,7 +180,13 @@ export interface AnimationOptions { */ export type GuardOptions = | { readonly redirect: string } - | { readonly fallback: () => Element.Element }; + | { + readonly fallback: () => Element.Element< + HTMLElement | SVGElement, + never, + never + >; + }; /** * A handler entry for POST/PUT/DELETE mutations. @@ -231,10 +262,10 @@ export interface Route< * Stored opaquely; the router never executes it. */ readonly _loader: - | ((args: { + | ((args: { params: Params; searchParams: SearchParams; - }) => Effect.Effect) + }) => Effect.Effect) | null; /** * Mutation handlers — executed on POST/PUT/DELETE by platform. @@ -841,7 +872,9 @@ export const lazy = ( load: () => Promise<{ default: Route; }>, - options?: { fallback?: () => Element.Element }, + options?: { + fallback?: () => Element.Element; + }, ): Route => { // Create a placeholder route that will be replaced when loaded const segments = parsePath(path); diff --git a/packages/router/src/RouteData.ts b/packages/router/src/RouteData.ts index 4a9d4329..ea2c1e97 100644 --- a/packages/router/src/RouteData.ts +++ b/packages/router/src/RouteData.ts @@ -59,8 +59,8 @@ export class RouteDataContext extends Context.Tag("@effex/router/RouteData")< * - Client (navigation): fetches from server via `?_data=1` */ export interface RouteDataProviderService { - readonly getRouteData: ( - route: Route, + readonly getRouteData: ( + route: Route, params: Record, searchParams: Record, ) => Effect.Effect; diff --git a/packages/router/src/Router.ts b/packages/router/src/Router.ts index 1f6ddf69..412c7eec 100644 --- a/packages/router/src/Router.ts +++ b/packages/router/src/Router.ts @@ -1,4 +1,4 @@ -import { Effect, Option, Pipeable, Schema } from "effect"; +import { Effect, Option, Pipeable, Record, Schema } from "effect"; import { Readable } from "@effex/core"; import type { Element } from "@effex/dom"; @@ -43,12 +43,17 @@ export interface MatchOptions { /** * A router containing routes and configuration. */ -export interface Router extends Pipeable.Pipeable { +export interface Router< + P extends Record | never = never, + S extends Record | never = never, + D = never, + E = never, + R = never, +> + extends Pipeable.Pipeable { readonly [TypeId]: TypeId; /** All routes in this router */ - readonly routes: ReadonlyArray< - Route - >; + readonly routes: ReadonlyArray>; /** Fallback render function when no route matches */ readonly fallback: | (() => Element.Element) @@ -72,14 +77,11 @@ const RouterProto = { /** * An empty router with no routes. */ -export const empty: Router = Object.assign( - Object.create(RouterProto), - { - routes: [], - fallback: null, - layouts: [], - }, -); +export const empty: Router = Object.assign(Object.create(RouterProto), { + routes: [], + fallback: null, + layouts: [], +}); // ============================================================================= // Combinators @@ -99,28 +101,69 @@ export const empty: Router = Object.assign( * ``` */ export const concat: { - ( - route: Route, - ): (router: Router) => Router; - ( - other: Router, - ): (router: Router) => Router; + < + Path extends string, + P extends Record | never, + S extends Record | never, + D, + E, + R, + >( + route: Route, + ): < + P2 extends Record | never, + S2 extends Record | never, + D2, + E2, + R2, + >( + router: Router, + ) => Router

    ; + < + P extends Record | never, + S extends Record | never, + D, + E, + R, + >( + other: Router, + ): < + P2 extends Record | never, + S2 extends Record | never, + D2, + E2, + R2, + >( + router: Router, + ) => Router

    ; } = - ( - routeOrRouter: - | Route - | Router, + < + P extends Record | never, + S extends Record | never, + D, + E, + R, + >( + routeOrRouter: Route | Router, ) => - (router: Router): Router => { + < + P2 extends Record | never, + S2 extends Record | never, + D2, + E2, + R2, + >( + router: Router, + ): Router

    => { if (isRoute(routeOrRouter)) { // Adding a single route return Object.assign(Object.create(RouterProto), { ...router, routes: [...router.routes, routeOrRouter], - }) as Router; + }) as Router

    ; } // Merging another router - const other = routeOrRouter as Router; + const other = routeOrRouter as Router; return Object.assign(Object.create(RouterProto), { ...router, routes: [...router.routes, ...other.routes], @@ -128,7 +171,7 @@ export const concat: { fallback: other.fallback ?? router.fallback, // Combine layouts (router's layouts applied first, then other's) layouts: [...router.layouts, ...other.layouts], - }) as Router; + }) as Router

    ; }; /** @@ -146,7 +189,15 @@ export const concat: { */ export const prefixAll = (prefix: string) => - (router: Router): Router => { + < + P extends Record | never, + S extends Record | never, + D, + E, + R, + >( + router: Router, + ): Router => { const normalizedPrefix = prefix.endsWith("/") ? prefix.slice(0, -1) : prefix; @@ -167,7 +218,7 @@ export const prefixAll = return Object.assign(Object.create(RouterProto), { ...router, routes: prefixedRoutes, - }) as Router; + }) as Router; }; /** @@ -183,11 +234,19 @@ export const prefixAll = */ export const fallback = (render: () => Element.Element) => - (router: Router): Router => { + < + P extends Record, + S extends Record, + D, + E2, + R2, + >( + router: Router, + ): Router => { return Object.assign(Object.create(RouterProto), { ...router, fallback: render, - }) as Router; + }) as Router; }; /** @@ -208,14 +267,34 @@ export const fallback = * ``` */ export const guard = - ( + < + P extends Record | never, + S extends Record | never, + D, + E, + R, + >( condition: Readable.Readable | Effect.Effect, - protectedRouter: Router, + protectedRouter: Router, options: | { redirect: string } - | { fallback: () => Element.Element }, + | { + fallback: () => Element.Element< + HTMLElement | SVGElement, + never, + never + >; + }, ) => - (router: Router): Router => { + < + P2 extends Record | never, + S2 extends Record | never, + D2, + E2, + R2, + >( + router: Router, + ): Router

    => { // Add guard to all routes in the protected router const guardedRoutes = protectedRouter.routes.map((route) => ({ ...route, @@ -226,7 +305,7 @@ export const guard = return Object.assign(Object.create(RouterProto), { ...router, routes: [...router.routes, ...guardedRoutes], - }) as Router; + }) as Router

    ; }; /** @@ -245,12 +324,24 @@ export const guard = * ``` */ export const layout = - (wrapper: LayoutWrapper) => - (router: Router): Router => { + ( + wrapper: ( + children: Element.Element, + ) => Element.Element, + ) => + < + P extends Record | never, + S extends Record | never, + D, + E2, + R2, + >( + router: Router, + ): Router => { return Object.assign(Object.create(RouterProto), { ...router, layouts: [...router.layouts, wrapper], - }) as Router; + }) as Router; }; // ============================================================================= @@ -273,23 +364,26 @@ export const layout = * ``` */ export const catchIf = - ( + < + P extends Record | never, + S extends Record | never, + D, + E, + E2, + R2, + >( predicate: (error: E) => boolean, handler: (error: E) => Element.Element, ) => - (router: Router): Router | E2, R | R2> => { + (router: Router): Router => { const transformedRoutes = router.routes.map((route) => ({ ...route, - render: (data: any) => + render: (data: D) => Effect.catchIf( route.render(data), predicate, handler, - ) as Element.Element< - HTMLElement | SVGElement, - Exclude | E2, - R | R2 - >, + ) as Element.Element, })); const transformedFallback = router.fallback @@ -298,18 +392,14 @@ export const catchIf = router.fallback!(), predicate, handler, - ) as Element.Element< - HTMLElement | SVGElement, - Exclude | E2, - R | R2 - > + ) as Element.Element : null; return Object.assign(Object.create(RouterProto), { ...router, routes: transformedRoutes, fallback: transformedFallback, - }) as Router | E2, R | R2>; + }) as Router; }; /** @@ -326,26 +416,40 @@ export const catchIf = * ``` */ export const catchTag: { - ( + < + const K extends string, + P extends Record | never, + S extends Record | never, + D, + E2, + R2, + >( tag: K, handler: (error: { _tag: K; }) => Element.Element, ): ( - router: Router, - ) => Router | E2, R | R2>; -} = (( + router: Router, + ) => Router | E2, R | R2>; +} = (< + const K extends string, + P extends Record | never, + S extends Record | never, + D, + E2, + R2, + >( tag: K, handler: (error: { _tag: K; }) => Element.Element, ) => ( - router: Router, - ): Router | E2, R | R2> => { + router: Router, + ): Router | E2, R | R2> => { const transformedRoutes = router.routes.map((route) => ({ ...route, - render: (data: any) => + render: (data: D) => Effect.catchTag( route.render(data) as Effect.Effect< HTMLElement | SVGElement, @@ -378,16 +482,23 @@ export const catchTag: { ...router, routes: transformedRoutes, fallback: transformedFallback, - }) as Router | E2, R | R2>; + }) as Router | E2, R | R2>; }) as { - ( + < + const K extends string, + P extends Record | never, + S extends Record | never, + D, + E2, + R2, + >( tag: K, handler: (error: { _tag: K; }) => Element.Element, ): ( - router: Router, - ) => Router | E2, R | R2>; + router: Router, + ) => Router | E2, R | R2>; }; /** @@ -404,13 +515,20 @@ export const catchTag: { * ``` */ export const catchAll = - ( + < + P extends Record | never, + S extends Record | never, + D, + E, + E2, + R2, + >( handler: (error: E) => Element.Element, ) => - (router: Router): Router => { + (router: Router): Router => { const transformedRoutes = router.routes.map((route) => ({ ...route, - render: (data: any) => + render: (data: D) => Effect.catchAll(route.render(data), handler) as Element.Element< HTMLElement | SVGElement, E2, @@ -431,7 +549,7 @@ export const catchAll = ...router, routes: transformedRoutes, fallback: transformedFallback, - }) as Router; + }) as Router; }; // ============================================================================= @@ -442,11 +560,17 @@ export const catchAll = * Find the best matching route for a pathname. * Routes are sorted by specificity - more specific routes match first. */ -export const findMatch = ( - router: Router, +export const findMatch = < + P extends Record | never, + S extends Record | never, + D, + E, + R, +>( + router: Router, pathname: string, ): Option.Option<{ - route: Route; + route: Route; params: Record; }> => { // Sort routes by specificity (descending) @@ -467,8 +591,14 @@ export const findMatch = ( /** * Parse and validate params using the route's schema. */ -export const parseParams = ( - route: Route, +export const parseParams = < + P extends Record | never, + S extends Record | never, + D, + E, + R, +>( + route: Route, rawParams: Record, ): Effect.Effect => { if (route.paramsSchema) { @@ -480,10 +610,16 @@ export const parseParams = ( /** * Parse search params from URLSearchParams. */ -export const parseSearchParams = ( - route: Route, +export const parseSearchParams = < + P extends Record | never, + S extends Record | never, + D, + E, + R, +>( + route: Route, searchParams: URLSearchParams, -): Effect.Effect => { +): Effect.Effect => { const raw: Record = {}; searchParams.forEach((value, key) => { raw[key] = value; @@ -492,7 +628,7 @@ export const parseSearchParams = ( if (route.searchParamsSchema) { return Schema.decodeUnknown(route.searchParamsSchema)(raw); } - return Effect.succeed(raw as SP); + return Effect.succeed(raw as S); }; // ============================================================================= diff --git a/packages/router/src/index.ts b/packages/router/src/index.ts index 94f250ec..b2d16f68 100644 --- a/packages/router/src/index.ts +++ b/packages/router/src/index.ts @@ -55,6 +55,8 @@ export { currentMatch as navCurrentMatch, pushPath as navPushPath, replacePath as navReplacePath, + pushRoute as navPushRoute, + replaceRoute as navReplaceRoute, back as navBack, forward as navForward, type Navigation as NavigationType, diff --git a/packages/vite-plugin/src/plugin.ts b/packages/vite-plugin/src/plugin.ts index 480cca33..9f6d3087 100644 --- a/packages/vite-plugin/src/plugin.ts +++ b/packages/vite-plugin/src/plugin.ts @@ -243,7 +243,7 @@ export const effexPlatform = (options: EffexPlatformOptions = {}): Plugin => { // ------------------------------------------------------------------------- async closeBundle() { - if (mode !== "ssg" || !entryPath || isSsr || isDev) return; + if (mode !== "ssg" || !entryPath || !isSsr || isDev) return; try { // Dynamically import the built SSG entry. @@ -256,10 +256,11 @@ export const effexPlatform = (options: EffexPlatformOptions = {}): Plugin => { // eslint-disable-next-line @typescript-eslint/no-explicit-any const { buildStaticSite } = (await import(platformModule)) as any; - // Try to load from the built output first, fall back to source - const entryModule = await import( - /* @vite-ignore */ path.resolve(root, entryPath) - ); + // Import the built SSR entry from the output directory. + // Vite's SSR build outputs `src/entry.ts` as `entry.js` in outDir. + const entryBasename = path.basename(entryPath, path.extname(entryPath)); + const builtEntry = path.resolve(outDir, `${entryBasename}.js`); + const entryModule = await import(/* @vite-ignore */ builtEntry); if (!entryModule.router) { throw new Error( @@ -286,6 +287,63 @@ export const effexPlatform = (options: EffexPlatformOptions = {}): Plugin => { // Server-code stripping internals // ============================================================================= +/** + * Remove import declarations whose specifiers are no longer referenced + * in the rest of the code. This prevents server-only modules from being + * evaluated after their call sites have been stripped. + * + * Only removes named imports (e.g. `import { a, b } from "..."`) where + * every imported name is unreferenced. Side-effect imports (`import "..."`) + * and namespace imports (`import * as x`) are left alone. + */ +const stripDeadImports = (code: string): string => { + const importRe = /^import\s+\{([^}]+)\}\s+from\s+["'][^"']+["'];?\s*$/gm; + let result = code; + + const toRemove: { start: number; end: number }[] = []; + let match: RegExpExecArray | null; + + importRe.lastIndex = 0; + + while ((match = importRe.exec(code)) !== null) { + const specifiers = match[1] + .split(",") + .map((s) => { + const trimmed = s.trim().replace(/^type\s+/, ""); + const asMatch = trimmed.match(/\S+\s+as\s+(\S+)/); + return asMatch ? asMatch[1] : trimmed; + }) + .filter((s) => s.length > 0); + + if (specifiers.length === 0) continue; + + const importStart = match.index; + const importEnd = match.index + match[0].length; + const codeWithout = code.slice(0, importStart) + code.slice(importEnd); + + const allDead = specifiers.every((name) => { + const re = new RegExp(`\\b${escapeRegExp(name)}\\b`); + return !re.test(codeWithout); + }); + + if (allDead) { + toRemove.push({ start: importStart, end: importEnd }); + } + } + + for (let i = toRemove.length - 1; i >= 0; i--) { + const { start, end } = toRemove[i]; + const actualEnd = + end < result.length && result[end] === "\n" ? end + 1 : end; + result = result.slice(0, start) + result.slice(actualEnd); + } + + return result; +}; + +const escapeRegExp = (s: string): string => + s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + /** * Strip server-only code from route definitions. * @@ -299,6 +357,7 @@ export const stripServerCode = (code: string): string => { result = stripLoaders(result); result = stripHandlers(result); result = stripStaticConfig(result); + result = stripDeadImports(result); return result; }; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 0ade4dd7..b9af7449 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -28,10 +28,10 @@ importers: version: 24.10.4 '@vitest/browser-playwright': specifier: ^4.0.16 - version: 4.0.16(playwright@1.57.0)(vite@7.3.0(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)(yaml@2.8.2))(vitest@4.0.16) + version: 4.0.16(playwright@1.57.0)(vite@7.3.0(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.31.1)(tsx@4.21.0)(yaml@2.8.2))(vitest@4.0.16) '@vitest/coverage-v8': specifier: ^4.0.16 - version: 4.0.16(@vitest/browser@4.0.16(vite@7.3.0(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)(yaml@2.8.2))(vitest@4.0.16))(vitest@4.0.16) + version: 4.0.16(@vitest/browser@4.0.16(vite@7.3.0(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.31.1)(tsx@4.21.0)(yaml@2.8.2))(vitest@4.0.16))(vitest@4.0.16) eslint: specifier: ^9.39.2 version: 9.39.2(jiti@2.6.1) @@ -76,10 +76,10 @@ importers: version: 8.50.1(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) vite: specifier: ^7.3.0 - version: 7.3.0(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)(yaml@2.8.2) + version: 7.3.0(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.31.1)(tsx@4.21.0)(yaml@2.8.2) vitest: specifier: ^4.0.16 - version: 4.0.16(@types/node@24.10.4)(@vitest/browser-playwright@4.0.16)(jiti@2.6.1)(jsdom@27.3.0)(lightningcss@1.30.2)(tsx@4.21.0)(yaml@2.8.2) + version: 4.0.16(@types/node@24.10.4)(@vitest/browser-playwright@4.0.16)(jiti@2.6.1)(jsdom@27.3.0)(lightningcss@1.31.1)(tsx@4.21.0)(yaml@2.8.2) apps/docs: dependencies: @@ -95,12 +95,30 @@ importers: '@effex/router': specifier: workspace:* version: link:../../packages/router + '@shikijs/markdown-it': + specifier: ^4.0.2 + version: 4.0.2 + '@tailwindcss/vite': + specifier: ^4.2.1 + version: 4.2.1(vite@7.3.0(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.31.1)(tsx@4.21.0)(yaml@2.8.2)) + daisyui: + specifier: ^5.5.19 + version: 5.5.19 effect: specifier: 3.19.13 version: 3.19.13 + lucide-static: + specifier: ^0.576.0 + version: 0.576.0 markdown-it: specifier: ^14.1.0 version: 14.1.0 + shiki: + specifier: ^4.0.2 + version: 4.0.2 + tailwindcss: + specifier: ^4.2.1 + version: 4.2.1 devDependencies: '@effex/vite-plugin': specifier: workspace:* @@ -113,7 +131,7 @@ importers: version: 5.9.3 vite: specifier: ^7.0.0 - version: 7.3.0(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)(yaml@2.8.2) + version: 7.3.0(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.31.1)(tsx@4.21.0)(yaml@2.8.2) packages/core: dependencies: @@ -190,7 +208,7 @@ importers: devDependencies: vite: specifier: ^7.0.0 - version: 7.3.0(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)(yaml@2.8.2) + version: 7.3.0(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.31.1)(tsx@4.21.0)(yaml@2.8.2) packages: @@ -361,158 +379,106 @@ packages: '@esbuild/aix-ppc64@0.27.2': resolution: {integrity: sha512-GZMB+a0mOMZs4MpDbj8RJp4cw+w1WV5NYD6xzgvzUJ5Ek2jerwfO2eADyI6ExDSUED+1X8aMbegahsJi+8mgpw==} engines: {node: '>=18'} - cpu: [ppc64] - os: [aix] '@esbuild/android-arm64@0.27.2': resolution: {integrity: sha512-pvz8ZZ7ot/RBphf8fv60ljmaoydPU12VuXHImtAs0XhLLw+EXBi2BLe3OYSBslR4rryHvweW5gmkKFwTiFy6KA==} engines: {node: '>=18'} - cpu: [arm64] - os: [android] '@esbuild/android-arm@0.27.2': resolution: {integrity: sha512-DVNI8jlPa7Ujbr1yjU2PfUSRtAUZPG9I1RwW4F4xFB1Imiu2on0ADiI/c3td+KmDtVKNbi+nffGDQMfcIMkwIA==} engines: {node: '>=18'} - cpu: [arm] - os: [android] '@esbuild/android-x64@0.27.2': resolution: {integrity: sha512-z8Ank4Byh4TJJOh4wpz8g2vDy75zFL0TlZlkUkEwYXuPSgX8yzep596n6mT7905kA9uHZsf/o2OJZubl2l3M7A==} engines: {node: '>=18'} - cpu: [x64] - os: [android] '@esbuild/darwin-arm64@0.27.2': resolution: {integrity: sha512-davCD2Zc80nzDVRwXTcQP/28fiJbcOwvdolL0sOiOsbwBa72kegmVU0Wrh1MYrbuCL98Omp5dVhQFWRKR2ZAlg==} engines: {node: '>=18'} - cpu: [arm64] - os: [darwin] '@esbuild/darwin-x64@0.27.2': resolution: {integrity: sha512-ZxtijOmlQCBWGwbVmwOF/UCzuGIbUkqB1faQRf5akQmxRJ1ujusWsb3CVfk/9iZKr2L5SMU5wPBi1UWbvL+VQA==} engines: {node: '>=18'} - cpu: [x64] - os: [darwin] '@esbuild/freebsd-arm64@0.27.2': resolution: {integrity: sha512-lS/9CN+rgqQ9czogxlMcBMGd+l8Q3Nj1MFQwBZJyoEKI50XGxwuzznYdwcav6lpOGv5BqaZXqvBSiB/kJ5op+g==} engines: {node: '>=18'} - cpu: [arm64] - os: [freebsd] '@esbuild/freebsd-x64@0.27.2': resolution: {integrity: sha512-tAfqtNYb4YgPnJlEFu4c212HYjQWSO/w/h/lQaBK7RbwGIkBOuNKQI9tqWzx7Wtp7bTPaGC6MJvWI608P3wXYA==} engines: {node: '>=18'} - cpu: [x64] - os: [freebsd] '@esbuild/linux-arm64@0.27.2': resolution: {integrity: sha512-hYxN8pr66NsCCiRFkHUAsxylNOcAQaxSSkHMMjcpx0si13t1LHFphxJZUiGwojB1a/Hd5OiPIqDdXONia6bhTw==} engines: {node: '>=18'} - cpu: [arm64] - os: [linux] '@esbuild/linux-arm@0.27.2': resolution: {integrity: sha512-vWfq4GaIMP9AIe4yj1ZUW18RDhx6EPQKjwe7n8BbIecFtCQG4CfHGaHuh7fdfq+y3LIA2vGS/o9ZBGVxIDi9hw==} engines: {node: '>=18'} - cpu: [arm] - os: [linux] '@esbuild/linux-ia32@0.27.2': resolution: {integrity: sha512-MJt5BRRSScPDwG2hLelYhAAKh9imjHK5+NE/tvnRLbIqUWa+0E9N4WNMjmp/kXXPHZGqPLxggwVhz7QP8CTR8w==} engines: {node: '>=18'} - cpu: [ia32] - os: [linux] '@esbuild/linux-loong64@0.27.2': resolution: {integrity: sha512-lugyF1atnAT463aO6KPshVCJK5NgRnU4yb3FUumyVz+cGvZbontBgzeGFO1nF+dPueHD367a2ZXe1NtUkAjOtg==} engines: {node: '>=18'} - cpu: [loong64] - os: [linux] '@esbuild/linux-mips64el@0.27.2': resolution: {integrity: sha512-nlP2I6ArEBewvJ2gjrrkESEZkB5mIoaTswuqNFRv/WYd+ATtUpe9Y09RnJvgvdag7he0OWgEZWhviS1OTOKixw==} engines: {node: '>=18'} - cpu: [mips64el] - os: [linux] '@esbuild/linux-ppc64@0.27.2': resolution: {integrity: sha512-C92gnpey7tUQONqg1n6dKVbx3vphKtTHJaNG2Ok9lGwbZil6DrfyecMsp9CrmXGQJmZ7iiVXvvZH6Ml5hL6XdQ==} engines: {node: '>=18'} - cpu: [ppc64] - os: [linux] '@esbuild/linux-riscv64@0.27.2': resolution: {integrity: sha512-B5BOmojNtUyN8AXlK0QJyvjEZkWwy/FKvakkTDCziX95AowLZKR6aCDhG7LeF7uMCXEJqwa8Bejz5LTPYm8AvA==} engines: {node: '>=18'} - cpu: [riscv64] - os: [linux] '@esbuild/linux-s390x@0.27.2': resolution: {integrity: sha512-p4bm9+wsPwup5Z8f4EpfN63qNagQ47Ua2znaqGH6bqLlmJ4bx97Y9JdqxgGZ6Y8xVTixUnEkoKSHcpRlDnNr5w==} engines: {node: '>=18'} - cpu: [s390x] - os: [linux] '@esbuild/linux-x64@0.27.2': resolution: {integrity: sha512-uwp2Tip5aPmH+NRUwTcfLb+W32WXjpFejTIOWZFw/v7/KnpCDKG66u4DLcurQpiYTiYwQ9B7KOeMJvLCu/OvbA==} engines: {node: '>=18'} - cpu: [x64] - os: [linux] '@esbuild/netbsd-arm64@0.27.2': resolution: {integrity: sha512-Kj6DiBlwXrPsCRDeRvGAUb/LNrBASrfqAIok+xB0LxK8CHqxZ037viF13ugfsIpePH93mX7xfJp97cyDuTZ3cw==} engines: {node: '>=18'} - cpu: [arm64] - os: [netbsd] '@esbuild/netbsd-x64@0.27.2': resolution: {integrity: sha512-HwGDZ0VLVBY3Y+Nw0JexZy9o/nUAWq9MlV7cahpaXKW6TOzfVno3y3/M8Ga8u8Yr7GldLOov27xiCnqRZf0tCA==} engines: {node: '>=18'} - cpu: [x64] - os: [netbsd] '@esbuild/openbsd-arm64@0.27.2': resolution: {integrity: sha512-DNIHH2BPQ5551A7oSHD0CKbwIA/Ox7+78/AWkbS5QoRzaqlev2uFayfSxq68EkonB+IKjiuxBFoV8ESJy8bOHA==} engines: {node: '>=18'} - cpu: [arm64] - os: [openbsd] '@esbuild/openbsd-x64@0.27.2': resolution: {integrity: sha512-/it7w9Nb7+0KFIzjalNJVR5bOzA9Vay+yIPLVHfIQYG/j+j9VTH84aNB8ExGKPU4AzfaEvN9/V4HV+F+vo8OEg==} engines: {node: '>=18'} - cpu: [x64] - os: [openbsd] '@esbuild/openharmony-arm64@0.27.2': resolution: {integrity: sha512-LRBbCmiU51IXfeXk59csuX/aSaToeG7w48nMwA6049Y4J4+VbWALAuXcs+qcD04rHDuSCSRKdmY63sruDS5qag==} engines: {node: '>=18'} - cpu: [arm64] - os: [openharmony] '@esbuild/sunos-x64@0.27.2': resolution: {integrity: sha512-kMtx1yqJHTmqaqHPAzKCAkDaKsffmXkPHThSfRwZGyuqyIeBvf08KSsYXl+abf5HDAPMJIPnbBfXvP2ZC2TfHg==} engines: {node: '>=18'} - cpu: [x64] - os: [sunos] '@esbuild/win32-arm64@0.27.2': resolution: {integrity: sha512-Yaf78O/B3Kkh+nKABUF++bvJv5Ijoy9AN1ww904rOXZFLWVc5OLOfL56W+C8F9xn5JQZa3UX6m+IktJnIb1Jjg==} engines: {node: '>=18'} - cpu: [arm64] - os: [win32] '@esbuild/win32-ia32@0.27.2': resolution: {integrity: sha512-Iuws0kxo4yusk7sw70Xa2E2imZU5HoixzxfGCdxwBdhiDgt9vX9VUCBhqcwY7/uh//78A1hMkkROMJq9l27oLQ==} engines: {node: '>=18'} - cpu: [ia32] - os: [win32] '@esbuild/win32-x64@0.27.2': resolution: {integrity: sha512-sRdU18mcKf7F+YgheI/zGf5alZatMUTKj/jNS6l744f9u3WFu4v7twcUI9vu4mknF4Y9aDlblIie0IM+5xxaqQ==} engines: {node: '>=18'} - cpu: [x64] - os: [win32] '@eslint-community/eslint-utils@4.9.0': resolution: {integrity: sha512-ayVFHdtZ+hsq1t2Dy24wCmGXGe4q9Gu3smhLYALJrr473ZH27MsnSL+LKUlimp4BWJqMDMLmPpx/Q9R3OAlL4g==} @@ -609,6 +575,9 @@ packages: '@jridgewell/gen-mapping@0.3.13': resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==} + '@jridgewell/remapping@2.3.5': + resolution: {integrity: sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==} + '@jridgewell/resolve-uri@3.1.2': resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==} engines: {node: '>=6.0.0'} @@ -627,33 +596,21 @@ packages: '@msgpackr-extract/msgpackr-extract-darwin-arm64@3.0.3': resolution: {integrity: sha512-QZHtlVgbAdy2zAqNA9Gu1UpIuI8Xvsd1v8ic6B2pZmeFnFcMWiPLfWXh7TVw4eGEZ/C9TH281KwhVoeQUKbyjw==} - cpu: [arm64] - os: [darwin] '@msgpackr-extract/msgpackr-extract-darwin-x64@3.0.3': resolution: {integrity: sha512-mdzd3AVzYKuUmiWOQ8GNhl64/IoFGol569zNRdkLReh6LRLHOXxU4U8eq0JwaD8iFHdVGqSy4IjFL4reoWCDFw==} - cpu: [x64] - os: [darwin] '@msgpackr-extract/msgpackr-extract-linux-arm64@3.0.3': resolution: {integrity: sha512-YxQL+ax0XqBJDZiKimS2XQaf+2wDGVa1enVRGzEvLLVFeqa5kx2bWbtcSXgsxjQB7nRqqIGFIcLteF/sHeVtQg==} - cpu: [arm64] - os: [linux] '@msgpackr-extract/msgpackr-extract-linux-arm@3.0.3': resolution: {integrity: sha512-fg0uy/dG/nZEXfYilKoRe7yALaNmHoYeIoJuJ7KJ+YyU2bvY8vPv27f7UKhGRpY6euFYqEVhxCFZgAUNQBM3nw==} - cpu: [arm] - os: [linux] '@msgpackr-extract/msgpackr-extract-linux-x64@3.0.3': resolution: {integrity: sha512-cvwNfbP07pKUfq1uH+S6KJ7dT9K8WOE4ZiAcsrSes+UY55E/0jLYc+vq+DO7jlmqRb5zAggExKm0H7O/CBaesg==} - cpu: [x64] - os: [linux] '@msgpackr-extract/msgpackr-extract-win32-x64@3.0.3': resolution: {integrity: sha512-x0fWaQtYp4E6sktbsdAqnehxDgEc/VwM7uLsRCYWaiGu0ykYdZPiS8zCWdnjHwyiumousxfBm4SO31eXqwEZhQ==} - cpu: [x64] - os: [win32] '@nodelib/fs.scandir@2.1.5': resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==} @@ -676,132 +633,192 @@ packages: '@rollup/rollup-android-arm-eabi@4.54.0': resolution: {integrity: sha512-OywsdRHrFvCdvsewAInDKCNyR3laPA2mc9bRYJ6LBp5IyvF3fvXbbNR0bSzHlZVFtn6E0xw2oZlyjg4rKCVcng==} - cpu: [arm] - os: [android] '@rollup/rollup-android-arm64@4.54.0': resolution: {integrity: sha512-Skx39Uv+u7H224Af+bDgNinitlmHyQX1K/atIA32JP3JQw6hVODX5tkbi2zof/E69M1qH2UoN3Xdxgs90mmNYw==} - cpu: [arm64] - os: [android] '@rollup/rollup-darwin-arm64@4.54.0': resolution: {integrity: sha512-k43D4qta/+6Fq+nCDhhv9yP2HdeKeP56QrUUTW7E6PhZP1US6NDqpJj4MY0jBHlJivVJD5P8NxrjuobZBJTCRw==} - cpu: [arm64] - os: [darwin] '@rollup/rollup-darwin-x64@4.54.0': resolution: {integrity: sha512-cOo7biqwkpawslEfox5Vs8/qj83M/aZCSSNIWpVzfU2CYHa2G3P1UN5WF01RdTHSgCkri7XOlTdtk17BezlV3A==} - cpu: [x64] - os: [darwin] '@rollup/rollup-freebsd-arm64@4.54.0': resolution: {integrity: sha512-miSvuFkmvFbgJ1BevMa4CPCFt5MPGw094knM64W9I0giUIMMmRYcGW/JWZDriaw/k1kOBtsWh1z6nIFV1vPNtA==} - cpu: [arm64] - os: [freebsd] '@rollup/rollup-freebsd-x64@4.54.0': resolution: {integrity: sha512-KGXIs55+b/ZfZsq9aR026tmr/+7tq6VG6MsnrvF4H8VhwflTIuYh+LFUlIsRdQSgrgmtM3fVATzEAj4hBQlaqQ==} - cpu: [x64] - os: [freebsd] '@rollup/rollup-linux-arm-gnueabihf@4.54.0': resolution: {integrity: sha512-EHMUcDwhtdRGlXZsGSIuXSYwD5kOT9NVnx9sqzYiwAc91wfYOE1g1djOEDseZJKKqtHAHGwnGPQu3kytmfaXLQ==} - cpu: [arm] - os: [linux] '@rollup/rollup-linux-arm-musleabihf@4.54.0': resolution: {integrity: sha512-+pBrqEjaakN2ySv5RVrj/qLytYhPKEUwk+e3SFU5jTLHIcAtqh2rLrd/OkbNuHJpsBgxsD8ccJt5ga/SeG0JmA==} - cpu: [arm] - os: [linux] '@rollup/rollup-linux-arm64-gnu@4.54.0': resolution: {integrity: sha512-NSqc7rE9wuUaRBsBp5ckQ5CVz5aIRKCwsoa6WMF7G01sX3/qHUw/z4pv+D+ahL1EIKy6Enpcnz1RY8pf7bjwng==} - cpu: [arm64] - os: [linux] '@rollup/rollup-linux-arm64-musl@4.54.0': resolution: {integrity: sha512-gr5vDbg3Bakga5kbdpqx81m2n9IX8M6gIMlQQIXiLTNeQW6CucvuInJ91EuCJ/JYvc+rcLLsDFcfAD1K7fMofg==} - cpu: [arm64] - os: [linux] '@rollup/rollup-linux-loong64-gnu@4.54.0': resolution: {integrity: sha512-gsrtB1NA3ZYj2vq0Rzkylo9ylCtW/PhpLEivlgWe0bpgtX5+9j9EZa0wtZiCjgu6zmSeZWyI/e2YRX1URozpIw==} - cpu: [loong64] - os: [linux] '@rollup/rollup-linux-ppc64-gnu@4.54.0': resolution: {integrity: sha512-y3qNOfTBStmFNq+t4s7Tmc9hW2ENtPg8FeUD/VShI7rKxNW7O4fFeaYbMsd3tpFlIg1Q8IapFgy7Q9i2BqeBvA==} - cpu: [ppc64] - os: [linux] '@rollup/rollup-linux-riscv64-gnu@4.54.0': resolution: {integrity: sha512-89sepv7h2lIVPsFma8iwmccN7Yjjtgz0Rj/Ou6fEqg3HDhpCa+Et+YSufy27i6b0Wav69Qv4WBNl3Rs6pwhebQ==} - cpu: [riscv64] - os: [linux] '@rollup/rollup-linux-riscv64-musl@4.54.0': resolution: {integrity: sha512-ZcU77ieh0M2Q8Ur7D5X7KvK+UxbXeDHwiOt/CPSBTI1fBmeDMivW0dPkdqkT4rOgDjrDDBUed9x4EgraIKoR2A==} - cpu: [riscv64] - os: [linux] '@rollup/rollup-linux-s390x-gnu@4.54.0': resolution: {integrity: sha512-2AdWy5RdDF5+4YfG/YesGDDtbyJlC9LHmL6rZw6FurBJ5n4vFGupsOBGfwMRjBYH7qRQowT8D/U4LoSvVwOhSQ==} - cpu: [s390x] - os: [linux] '@rollup/rollup-linux-x64-gnu@4.54.0': resolution: {integrity: sha512-WGt5J8Ij/rvyqpFexxk3ffKqqbLf9AqrTBbWDk7ApGUzaIs6V+s2s84kAxklFwmMF/vBNGrVdYgbblCOFFezMQ==} - cpu: [x64] - os: [linux] '@rollup/rollup-linux-x64-musl@4.54.0': resolution: {integrity: sha512-JzQmb38ATzHjxlPHuTH6tE7ojnMKM2kYNzt44LO/jJi8BpceEC8QuXYA908n8r3CNuG/B3BV8VR3Hi1rYtmPiw==} - cpu: [x64] - os: [linux] '@rollup/rollup-openharmony-arm64@4.54.0': resolution: {integrity: sha512-huT3fd0iC7jigGh7n3q/+lfPcXxBi+om/Rs3yiFxjvSxbSB6aohDFXbWvlspaqjeOh+hx7DDHS+5Es5qRkWkZg==} - cpu: [arm64] - os: [openharmony] '@rollup/rollup-win32-arm64-msvc@4.54.0': resolution: {integrity: sha512-c2V0W1bsKIKfbLMBu/WGBz6Yci8nJ/ZJdheE0EwB73N3MvHYKiKGs3mVilX4Gs70eGeDaMqEob25Tw2Gb9Nqyw==} - cpu: [arm64] - os: [win32] '@rollup/rollup-win32-ia32-msvc@4.54.0': resolution: {integrity: sha512-woEHgqQqDCkAzrDhvDipnSirm5vxUXtSKDYTVpZG3nUdW/VVB5VdCYA2iReSj/u3yCZzXID4kuKG7OynPnB3WQ==} - cpu: [ia32] - os: [win32] '@rollup/rollup-win32-x64-gnu@4.54.0': resolution: {integrity: sha512-dzAc53LOuFvHwbCEOS0rPbXp6SIhAf2txMP5p6mGyOXXw5mWY8NGGbPMPrs4P1WItkfApDathBj/NzMLUZ9rtQ==} - cpu: [x64] - os: [win32] '@rollup/rollup-win32-x64-msvc@4.54.0': resolution: {integrity: sha512-hYT5d3YNdSh3mbCU1gwQyPgQd3T2ne0A3KG8KSBdav5TiBg6eInVmV+TeR5uHufiIgSFg0XsOWGW5/RhNcSvPg==} - cpu: [x64] - os: [win32] + + '@shikijs/core@4.0.2': + resolution: {integrity: sha512-hxT0YF4ExEqB8G/qFdtJvpmHXBYJ2lWW7qTHDarVkIudPFE6iCIrqdgWxGn5s+ppkGXI0aEGlibI0PAyzP3zlw==} + engines: {node: '>=20'} + + '@shikijs/engine-javascript@4.0.2': + resolution: {integrity: sha512-7PW0Nm49DcoUIQEXlJhNNBHyoGMjalRETTCcjMqEaMoJRLljy1Bi/EGV3/qLBgLKQejdspiiYuHGQW6dX94Nag==} + engines: {node: '>=20'} '@shikijs/engine-oniguruma@3.20.0': resolution: {integrity: sha512-Yx3gy7xLzM0ZOjqoxciHjA7dAt5tyzJE3L4uQoM83agahy+PlW244XJSrmJRSBvGYELDhYXPacD4R/cauV5bzQ==} + '@shikijs/engine-oniguruma@4.0.2': + resolution: {integrity: sha512-UpCB9Y2sUKlS9z8juFSKz7ZtysmeXCgnRF0dlhXBkmQnek7lAToPte8DkxmEYGNTMii72zU/lyXiCB6StuZeJg==} + engines: {node: '>=20'} + '@shikijs/langs@3.20.0': resolution: {integrity: sha512-le+bssCxcSHrygCWuOrYJHvjus6zhQ2K7q/0mgjiffRbkhM4o1EWu2m+29l0yEsHDbWaWPNnDUTRVVBvBBeKaA==} + '@shikijs/langs@4.0.2': + resolution: {integrity: sha512-KaXby5dvoeuZzN0rYQiPMjFoUrz4hgwIE+D6Du9owcHcl6/g16/yT5BQxSW5cGt2MZBz6Hl0YuRqf12omRfUUg==} + engines: {node: '>=20'} + + '@shikijs/markdown-it@4.0.2': + resolution: {integrity: sha512-7DDEhknj/mXTN7ME8CjKWBv5O/4YgOiJBZLgs/NbUFMC7Ik1x/VEhaK+aBjX60bJdok0E2mxEYan/GzJ2xRx+A==} + engines: {node: '>=20'} + peerDependencies: + markdown-it-async: ^2.2.0 + peerDependenciesMeta: + markdown-it-async: + optional: true + + '@shikijs/primitive@4.0.2': + resolution: {integrity: sha512-M6UMPrSa3fN5ayeJwFVl9qWofl273wtK1VG8ySDZ1mQBfhCpdd8nEx7nPZ/tk7k+TYcpqBZzj/AnwxT9lO+HJw==} + engines: {node: '>=20'} + '@shikijs/themes@3.20.0': resolution: {integrity: sha512-U1NSU7Sl26Q7ErRvJUouArxfM2euWqq1xaSrbqMu2iqa+tSp0D1Yah8216sDYbdDHw4C8b75UpE65eWorm2erQ==} + '@shikijs/themes@4.0.2': + resolution: {integrity: sha512-mjCafwt8lJJaVSsQvNVrJumbnnj1RI8jbUKrPKgE6E3OvQKxnuRoBaYC51H4IGHePsGN/QtALglWBU7DoKDFnA==} + engines: {node: '>=20'} + '@shikijs/types@3.20.0': resolution: {integrity: sha512-lhYAATn10nkZcBQ0BlzSbJA3wcmL5MXUUF8d2Zzon6saZDlToKaiRX60n2+ZaHJCmXEcZRWNzn+k9vplr8Jhsw==} + '@shikijs/types@4.0.2': + resolution: {integrity: sha512-qzbeRooUTPnLE+sHD/Z8DStmaDgnbbc/pMrU203950aRqjX/6AFHeDYT+j00y2lPdz0ywJKx7o/7qnqTivtlXg==} + engines: {node: '>=20'} + '@shikijs/vscode-textmate@10.0.2': resolution: {integrity: sha512-83yeghZ2xxin3Nj8z1NMd/NCuca+gsYXswywDy5bHvwlWL8tpTQmzGeUuHd9FC3E/SBEMvzJRwWEOz5gGes9Qg==} '@standard-schema/spec@1.1.0': resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} + '@tailwindcss/node@4.2.1': + resolution: {integrity: sha512-jlx6sLk4EOwO6hHe1oCGm1Q4AN/s0rSrTTPBGPM0/RQ6Uylwq17FuU8IeJJKEjtc6K6O07zsvP+gDO6MMWo7pg==} + + '@tailwindcss/oxide-android-arm64@4.2.1': + resolution: {integrity: sha512-eZ7G1Zm5EC8OOKaesIKuw77jw++QJ2lL9N+dDpdQiAB/c/B2wDh0QPFHbkBVrXnwNugvrbJFk1gK2SsVjwWReg==} + engines: {node: '>= 20'} + + '@tailwindcss/oxide-darwin-arm64@4.2.1': + resolution: {integrity: sha512-q/LHkOstoJ7pI1J0q6djesLzRvQSIfEto148ppAd+BVQK0JYjQIFSK3JgYZJa+Yzi0DDa52ZsQx2rqytBnf8Hw==} + engines: {node: '>= 20'} + + '@tailwindcss/oxide-darwin-x64@4.2.1': + resolution: {integrity: sha512-/f/ozlaXGY6QLbpvd/kFTro2l18f7dHKpB+ieXz+Cijl4Mt9AI2rTrpq7V+t04nK+j9XBQHnSMdeQRhbGyt6fw==} + engines: {node: '>= 20'} + + '@tailwindcss/oxide-freebsd-x64@4.2.1': + resolution: {integrity: sha512-5e/AkgYJT/cpbkys/OU2Ei2jdETCLlifwm7ogMC7/hksI2fC3iiq6OcXwjibcIjPung0kRtR3TxEITkqgn0TcA==} + engines: {node: '>= 20'} + + '@tailwindcss/oxide-linux-arm-gnueabihf@4.2.1': + resolution: {integrity: sha512-Uny1EcVTTmerCKt/1ZuKTkb0x8ZaiuYucg2/kImO5A5Y/kBz41/+j0gxUZl+hTF3xkWpDmHX+TaWhOtba2Fyuw==} + engines: {node: '>= 20'} + + '@tailwindcss/oxide-linux-arm64-gnu@4.2.1': + resolution: {integrity: sha512-CTrwomI+c7n6aSSQlsPL0roRiNMDQ/YzMD9EjcR+H4f0I1SQ8QqIuPnsVp7QgMkC1Qi8rtkekLkOFjo7OlEFRQ==} + engines: {node: '>= 20'} + + '@tailwindcss/oxide-linux-arm64-musl@4.2.1': + resolution: {integrity: sha512-WZA0CHRL/SP1TRbA5mp9htsppSEkWuQ4KsSUumYQnyl8ZdT39ntwqmz4IUHGN6p4XdSlYfJwM4rRzZLShHsGAQ==} + engines: {node: '>= 20'} + + '@tailwindcss/oxide-linux-x64-gnu@4.2.1': + resolution: {integrity: sha512-qMFzxI2YlBOLW5PhblzuSWlWfwLHaneBE0xHzLrBgNtqN6mWfs+qYbhryGSXQjFYB1Dzf5w+LN5qbUTPhW7Y5g==} + engines: {node: '>= 20'} + + '@tailwindcss/oxide-linux-x64-musl@4.2.1': + resolution: {integrity: sha512-5r1X2FKnCMUPlXTWRYpHdPYUY6a1Ar/t7P24OuiEdEOmms5lyqjDRvVY1yy9Rmioh+AunQ0rWiOTPE8F9A3v5g==} + engines: {node: '>= 20'} + + '@tailwindcss/oxide-wasm32-wasi@4.2.1': + resolution: {integrity: sha512-MGFB5cVPvshR85MTJkEvqDUnuNoysrsRxd6vnk1Lf2tbiqNlXpHYZqkqOQalydienEWOHHFyyuTSYRsLfxFJ2Q==} + engines: {node: '>=14.0.0'} + bundledDependencies: + - '@napi-rs/wasm-runtime' + - '@emnapi/core' + - '@emnapi/runtime' + - '@tybys/wasm-util' + - '@emnapi/wasi-threads' + - tslib + + '@tailwindcss/oxide-win32-arm64-msvc@4.2.1': + resolution: {integrity: sha512-YlUEHRHBGnCMh4Nj4GnqQyBtsshUPdiNroZj8VPkvTZSoHsilRCwXcVKnG9kyi0ZFAS/3u+qKHBdDc81SADTRA==} + engines: {node: '>= 20'} + + '@tailwindcss/oxide-win32-x64-msvc@4.2.1': + resolution: {integrity: sha512-rbO34G5sMWWyrN/idLeVxAZgAKWrn5LiR3/I90Q9MkA67s6T1oB0xtTe+0heoBvHSpbU9Mk7i6uwJnpo4u21XQ==} + engines: {node: '>= 20'} + + '@tailwindcss/oxide@4.2.1': + resolution: {integrity: sha512-yv9jeEFWnjKCI6/T3Oq50yQEOqmpmpfzG1hcZsAOaXFQPfzWprWrlHSdGPEF3WQTi8zu8ohC9Mh9J470nT5pUw==} + engines: {node: '>= 20'} + + '@tailwindcss/vite@4.2.1': + resolution: {integrity: sha512-TBf2sJjYeb28jD2U/OhwdW0bbOsxkWPwQ7SrqGf9sVcoYwZj7rkXljroBO9wKBut9XnmQLXanuDUeqQK0lGg/w==} + peerDependencies: + vite: ^5.2.0 || ^6 || ^7 + '@types/chai@5.2.3': resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==} @@ -823,6 +840,9 @@ packages: '@types/markdown-it@14.1.2': resolution: {integrity: sha512-promo4eFwuiW+TfGxhi+0x3czqTYJkG8qB17ZUJiVF10Xm7NLVRSLUsfRTU/6h1e24VvRnXCx+hG7li58lkzog==} + '@types/mdast@4.0.4': + resolution: {integrity: sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA==} + '@types/mdurl@2.0.0': resolution: {integrity: sha512-RGdgjQUZba5p6QEFAVx2OGb8rQDL/cPRG7GiedRzMcJ1tYnUANBncjbSB1NRGwbvjcPeikRABz2nshyPk1bhWg==} @@ -897,6 +917,9 @@ packages: resolution: {integrity: sha512-IrDKrw7pCRUR94zeuCSUWQ+w8JEf5ZX5jl/e6AHGSLi1/zIr0lgutfn/7JpfCey+urpgQEdrZVYzCaVVKiTwhQ==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + '@ungap/structured-clone@1.3.0': + resolution: {integrity: sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==} + '@vitest/browser-playwright@4.0.16': resolution: {integrity: sha512-I2Fy/ANdphi1yI46d15o0M1M4M0UJrUiVKkH5oKeRZZCdPg0fw/cfTKZzv9Ge9eobtJYp4BGblMzXdXH0vcl5g==} peerDependencies: @@ -1041,6 +1064,9 @@ packages: resolution: {integrity: sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==} engines: {node: '>=6'} + ccount@2.0.1: + resolution: {integrity: sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==} + chai@6.2.2: resolution: {integrity: sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==} engines: {node: '>=18'} @@ -1049,6 +1075,12 @@ packages: resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==} engines: {node: '>=10'} + character-entities-html4@2.1.0: + resolution: {integrity: sha512-1v7fgQRj6hnSwFpq1Eu0ynr/CDEw0rXo2B61qXrLNdHZmPKgb7fqS1a2JwF0rISo9q77jDI8VMEHoApn8qDoZA==} + + character-entities-legacy@3.0.0: + resolution: {integrity: sha512-RpPp0asT/6ufRm//AJVwpViZbGM/MkjQFxJccQRHmISF/22NBtsHqAWmL+/pmkPWoIUJdWyeVleTl1wydHATVQ==} + chardet@2.1.1: resolution: {integrity: sha512-PsezH1rqdV9VvyNhxxOW32/d75r01NY7TQCmOqomRo15ZSOKbpTFVsfjghxo6JloQUCGnH4k1LGu0R4yCLlWQQ==} @@ -1078,6 +1110,9 @@ packages: colorette@2.0.20: resolution: {integrity: sha512-IfEDxwoWIjkeXL1eXcDiow4UbKjhLdq6/EuSVR9GMN7KVH3r9gQ83e73hsz1Nd1T3ijd5xv1wcWRYO+D6kCI2w==} + comma-separated-tokens@2.0.3: + resolution: {integrity: sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg==} + commander@14.0.2: resolution: {integrity: sha512-TywoWNNRbhoD0BXs1P3ZEScW8W5iKrnbithIl0YH+uCmBd0QpPOA8yc82DS3BIE5Ma6FnBVUsJ7wVUDz4dvOWQ==} engines: {node: '>=20'} @@ -1108,6 +1143,9 @@ packages: resolution: {integrity: sha512-GlsEptulso7Jg0VaOZ8BXQi3AkYM5BOJKEO/rjMidSCq70FkIC5y0eawrCXeYzxgt3OCf4Ls+eoxN+/05vN0Ag==} engines: {node: '>=20'} + daisyui@5.5.19: + resolution: {integrity: sha512-pbFAkl1VCEh/MPCeclKL61I/MqRIFFhNU7yiXoDDRapXN4/qNCoMxeCCswyxEEhqL5eiTTfwHvucFtOE71C9sA==} + data-urls@6.0.0: resolution: {integrity: sha512-BnBS08aLUM+DKamupXs3w2tJJoqU+AkaE/+6vQxi/G/DPmIZFJJp9Dkb1kM03AZx8ADehDUZgsNxju3mPXZYIA==} engines: {node: '>=20'} @@ -1127,6 +1165,10 @@ packages: deep-is@0.1.4: resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==} + dequal@2.0.3: + resolution: {integrity: sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==} + engines: {node: '>=6'} + detect-indent@6.1.0: resolution: {integrity: sha512-reYkTUJAZb9gUuZ2RvVCNhVHdg62RHnJ7WJl8ftMi4diZ6NWlciOzQN88pUhSELEwflJht4oQDv0F0BMlwaYtA==} engines: {node: '>=8'} @@ -1135,6 +1177,9 @@ packages: resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} engines: {node: '>=8'} + devlop@1.1.0: + resolution: {integrity: sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA==} + dir-glob@3.0.1: resolution: {integrity: sha512-WkrWp9GR4KXfKGYzOLmTuGVi1UWFfws377n9cc55/tb6DuqyF6pcQ5AbiHEshaDpY9v6oaSr2XCDidGmMwdzIA==} engines: {node: '>=8'} @@ -1148,6 +1193,10 @@ packages: emoji-regex@10.6.0: resolution: {integrity: sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==} + enhanced-resolve@5.20.0: + resolution: {integrity: sha512-/ce7+jQ1PQ6rVXwe+jKEg5hW5ciicHwIQUagZkp6IufBoY3YDgdTTY1azVs0qoRgVmvsNB+rbjLJxDAeHHtwsQ==} + engines: {node: '>=10.13.0'} + enquirer@2.4.1: resolution: {integrity: sha512-rRqJg/6gd538VHvR3PSrdRBb/1Vy2YfzHqzvbhGIQpDRKIa4FgV/54b5Q1xYSxOOwKvjXweS26E0Q+nAMwp2pQ==} engines: {node: '>=8.6'} @@ -1328,12 +1377,10 @@ packages: fsevents@2.3.2: resolution: {integrity: sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==} engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} - os: [darwin] fsevents@2.3.3: resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} - os: [darwin] get-east-asian-width@1.4.0: resolution: {integrity: sha512-QZjmEOC+IT1uk6Rx0sX22V6uHWVwbdbxf1faPqJ1QhLdGgsRGCZoyaQBm/piRdJy/D2um6hM1UP7ZEeQ4EkP+Q==} @@ -1369,6 +1416,12 @@ packages: resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==} engines: {node: '>=8'} + hast-util-to-html@9.0.5: + resolution: {integrity: sha512-OguPdidb+fbHQSU4Q4ZiLKnzWo8Wwsf5bZfbvu7//a9oTYoqD/fWpe96NuHkoS9h0ccGOTe0C4NGXdtS0iObOw==} + + hast-util-whitespace@3.0.0: + resolution: {integrity: sha512-88JUN06ipLwsnv+dVn+OIYOvAuvBMy/Qoi6O7mQHxdPXpjy+Cd6xRkWwux7DKO+4sYILtLBRIKgsdpS2gQc7qw==} + html-encoding-sniffer@4.0.0: resolution: {integrity: sha512-Y22oTqIU4uuPgEemfz7NDJz6OeKf12Lsu+QC+s3BVpda64lTiMYCyGwg5ki4vFxkMwQdeZDl2adZoqUgdFuTgQ==} engines: {node: '>=18'} @@ -1376,6 +1429,9 @@ packages: html-escaper@2.0.2: resolution: {integrity: sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==} + html-void-elements@3.0.0: + resolution: {integrity: sha512-bEqo66MRXsUGxWHV5IP0PUiAWwoEjba4VCzg0LjFJBpchPaTfyfCKTG6bc5F8ucKec3q5y6qOdGyYTSBEvhCrg==} + http-proxy-agent@7.0.2: resolution: {integrity: sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==} engines: {node: '>= 14'} @@ -1522,74 +1578,52 @@ packages: resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==} engines: {node: '>= 0.8.0'} - lightningcss-android-arm64@1.30.2: - resolution: {integrity: sha512-BH9sEdOCahSgmkVhBLeU7Hc9DWeZ1Eb6wNS6Da8igvUwAe0sqROHddIlvU06q3WyXVEOYDZ6ykBZQnjTbmo4+A==} + lightningcss-android-arm64@1.31.1: + resolution: {integrity: sha512-HXJF3x8w9nQ4jbXRiNppBCqeZPIAfUo8zE/kOEGbW5NZvGc/K7nMxbhIr+YlFlHW5mpbg/YFPdbnCh1wAXCKFg==} engines: {node: '>= 12.0.0'} - cpu: [arm64] - os: [android] - lightningcss-darwin-arm64@1.30.2: - resolution: {integrity: sha512-ylTcDJBN3Hp21TdhRT5zBOIi73P6/W0qwvlFEk22fkdXchtNTOU4Qc37SkzV+EKYxLouZ6M4LG9NfZ1qkhhBWA==} + lightningcss-darwin-arm64@1.31.1: + resolution: {integrity: sha512-02uTEqf3vIfNMq3h/z2cJfcOXnQ0GRwQrkmPafhueLb2h7mqEidiCzkE4gBMEH65abHRiQvhdcQ+aP0D0g67sg==} engines: {node: '>= 12.0.0'} - cpu: [arm64] - os: [darwin] - lightningcss-darwin-x64@1.30.2: - resolution: {integrity: sha512-oBZgKchomuDYxr7ilwLcyms6BCyLn0z8J0+ZZmfpjwg9fRVZIR5/GMXd7r9RH94iDhld3UmSjBM6nXWM2TfZTQ==} + lightningcss-darwin-x64@1.31.1: + resolution: {integrity: sha512-1ObhyoCY+tGxtsz1lSx5NXCj3nirk0Y0kB/g8B8DT+sSx4G9djitg9ejFnjb3gJNWo7qXH4DIy2SUHvpoFwfTA==} engines: {node: '>= 12.0.0'} - cpu: [x64] - os: [darwin] - lightningcss-freebsd-x64@1.30.2: - resolution: {integrity: sha512-c2bH6xTrf4BDpK8MoGG4Bd6zAMZDAXS569UxCAGcA7IKbHNMlhGQ89eRmvpIUGfKWNVdbhSbkQaWhEoMGmGslA==} + lightningcss-freebsd-x64@1.31.1: + resolution: {integrity: sha512-1RINmQKAItO6ISxYgPwszQE1BrsVU5aB45ho6O42mu96UiZBxEXsuQ7cJW4zs4CEodPUioj/QrXW1r9pLUM74A==} engines: {node: '>= 12.0.0'} - cpu: [x64] - os: [freebsd] - lightningcss-linux-arm-gnueabihf@1.30.2: - resolution: {integrity: sha512-eVdpxh4wYcm0PofJIZVuYuLiqBIakQ9uFZmipf6LF/HRj5Bgm0eb3qL/mr1smyXIS1twwOxNWndd8z0E374hiA==} + lightningcss-linux-arm-gnueabihf@1.31.1: + resolution: {integrity: sha512-OOCm2//MZJ87CdDK62rZIu+aw9gBv4azMJuA8/KB74wmfS3lnC4yoPHm0uXZ/dvNNHmnZnB8XLAZzObeG0nS1g==} engines: {node: '>= 12.0.0'} - cpu: [arm] - os: [linux] - lightningcss-linux-arm64-gnu@1.30.2: - resolution: {integrity: sha512-UK65WJAbwIJbiBFXpxrbTNArtfuznvxAJw4Q2ZGlU8kPeDIWEX1dg3rn2veBVUylA2Ezg89ktszWbaQnxD/e3A==} + lightningcss-linux-arm64-gnu@1.31.1: + resolution: {integrity: sha512-WKyLWztD71rTnou4xAD5kQT+982wvca7E6QoLpoawZ1gP9JM0GJj4Tp5jMUh9B3AitHbRZ2/H3W5xQmdEOUlLg==} engines: {node: '>= 12.0.0'} - cpu: [arm64] - os: [linux] - lightningcss-linux-arm64-musl@1.30.2: - resolution: {integrity: sha512-5Vh9dGeblpTxWHpOx8iauV02popZDsCYMPIgiuw97OJ5uaDsL86cnqSFs5LZkG3ghHoX5isLgWzMs+eD1YzrnA==} + lightningcss-linux-arm64-musl@1.31.1: + resolution: {integrity: sha512-mVZ7Pg2zIbe3XlNbZJdjs86YViQFoJSpc41CbVmKBPiGmC4YrfeOyz65ms2qpAobVd7WQsbW4PdsSJEMymyIMg==} engines: {node: '>= 12.0.0'} - cpu: [arm64] - os: [linux] - lightningcss-linux-x64-gnu@1.30.2: - resolution: {integrity: sha512-Cfd46gdmj1vQ+lR6VRTTadNHu6ALuw2pKR9lYq4FnhvgBc4zWY1EtZcAc6EffShbb1MFrIPfLDXD6Xprbnni4w==} + lightningcss-linux-x64-gnu@1.31.1: + resolution: {integrity: sha512-xGlFWRMl+0KvUhgySdIaReQdB4FNudfUTARn7q0hh/V67PVGCs3ADFjw+6++kG1RNd0zdGRlEKa+T13/tQjPMA==} engines: {node: '>= 12.0.0'} - cpu: [x64] - os: [linux] - lightningcss-linux-x64-musl@1.30.2: - resolution: {integrity: sha512-XJaLUUFXb6/QG2lGIW6aIk6jKdtjtcffUT0NKvIqhSBY3hh9Ch+1LCeH80dR9q9LBjG3ewbDjnumefsLsP6aiA==} + lightningcss-linux-x64-musl@1.31.1: + resolution: {integrity: sha512-eowF8PrKHw9LpoZii5tdZwnBcYDxRw2rRCyvAXLi34iyeYfqCQNA9rmUM0ce62NlPhCvof1+9ivRaTY6pSKDaA==} engines: {node: '>= 12.0.0'} - cpu: [x64] - os: [linux] - lightningcss-win32-arm64-msvc@1.30.2: - resolution: {integrity: sha512-FZn+vaj7zLv//D/192WFFVA0RgHawIcHqLX9xuWiQt7P0PtdFEVaxgF9rjM/IRYHQXNnk61/H/gb2Ei+kUQ4xQ==} + lightningcss-win32-arm64-msvc@1.31.1: + resolution: {integrity: sha512-aJReEbSEQzx1uBlQizAOBSjcmr9dCdL3XuC/6HLXAxmtErsj2ICo5yYggg1qOODQMtnjNQv2UHb9NpOuFtYe4w==} engines: {node: '>= 12.0.0'} - cpu: [arm64] - os: [win32] - lightningcss-win32-x64-msvc@1.30.2: - resolution: {integrity: sha512-5g1yc73p+iAkid5phb4oVFMB45417DkRevRbt/El/gKXJk4jid+vPFF/AXbxn05Aky8PapwzZrdJShv5C0avjw==} + lightningcss-win32-x64-msvc@1.31.1: + resolution: {integrity: sha512-I9aiFrbd7oYHwlnQDqr1Roz+fTz61oDDJX7n9tYF9FJymH1cIN1DtKw3iYt6b8WZgEjoNwVSncwF4wx/ZedMhw==} engines: {node: '>= 12.0.0'} - cpu: [x64] - os: [win32] - lightningcss@1.30.2: - resolution: {integrity: sha512-utfs7Pr5uJyyvDETitgsaqSyjCb2qNRAtuqUeWIAKztsOYdcACf2KtARYXg2pSvhkt+9NfoaNY7fxjl6nuMjIQ==} + lightningcss@1.31.1: + resolution: {integrity: sha512-l51N2r93WmGUye3WuFoN5k10zyvrVs0qfKBhyC5ogUQ6Ew6JUSswh78mbSO+IU3nTWsyOArqPCcShdQSadghBQ==} engines: {node: '>= 12.0.0'} lilconfig@3.1.3: @@ -1637,6 +1671,9 @@ packages: resolution: {integrity: sha512-B5Y16Jr9LB9dHVkh6ZevG+vAbOsNOYCX+sXvFWFu7B3Iz5mijW3zdbMyhsh8ANd2mSWBYdJgnqi+mL7/LrOPYg==} engines: {node: 20 || >=22} + lucide-static@0.576.0: + resolution: {integrity: sha512-0LPnsw/0/PC8Diu0neT6lSsR/ZaCQRu2B4d15joy5OFlSrhyJi5Jc8p1aYOn2wqb4dUECxQ/fk1s4iSlKYvqqA==} + lunr@2.3.9: resolution: {integrity: sha512-zTU3DaZaF3Rt9rhN3uBMGQD3dD2/vFQqnvZCDv4dl5iOzq2IZQqTxu90r4E5J+nP70J3ilqVCrbho2eWaeW8Ow==} @@ -1654,6 +1691,13 @@ packages: resolution: {integrity: sha512-a54IwgWPaeBCAAsv13YgmALOF1elABB08FxO9i+r4VFk5Vl4pKokRPeX8u5TCgSsPi6ec1otfLjdOpVcgbpshg==} hasBin: true + markdown-it@14.1.1: + resolution: {integrity: sha512-BuU2qnTti9YKgK5N+IeMubp14ZUKUUw7yeJbkjtosvHiP0AZ5c8IAgEMk79D0eC8F23r4Ac/q8cAIFdm2FtyoA==} + hasBin: true + + mdast-util-to-hast@13.2.1: + resolution: {integrity: sha512-cctsq2wp5vTsLIcaymblUriiTcZd0CwWtCbLvrOzYCDZoWyMNV8sZ7krj09FSnsiJi3WVsHLM4k6Dq/yaPyCXA==} + mdn-data@2.12.2: resolution: {integrity: sha512-IEn+pegP1aManZuckezWCO+XZQDplx1366JoVhTpMpBB1sPey/SbveZQUosKiKiGYjg1wH4pMlNgXbCiYgihQA==} @@ -1664,6 +1708,21 @@ packages: resolution: {integrity: sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==} engines: {node: '>= 8'} + micromark-util-character@2.1.1: + resolution: {integrity: sha512-wv8tdUTJ3thSFFFJKtpYKOYiGP2+v96Hvk4Tu8KpCAsTMs6yi+nVmGh1syvSCsaxz45J6Jbw+9DD6g97+NV67Q==} + + micromark-util-encode@2.0.1: + resolution: {integrity: sha512-c3cVx2y4KqUnwopcO9b/SCdo2O67LwJJ/UyqGfbigahfegL9myoEFoDYZgkT7f36T0bLrM9hZTAaAyH+PCAXjw==} + + micromark-util-sanitize-uri@2.0.1: + resolution: {integrity: sha512-9N9IomZ/YuGGZZmQec1MbgxtlgougxTodVwDzzEouPKo3qFWvymFHWcnDi2vzV1ff6kas9ucW+o3yzJK9YB1AQ==} + + micromark-util-symbol@2.0.1: + resolution: {integrity: sha512-vs5t8Apaud9N28kgCrRUdEed4UJ+wWNvicHLPxCa9ENlYuAY31M0ETy5y1vA33YoNPDFTghEbnh6efaE8h4x0Q==} + + micromark-util-types@2.0.2: + resolution: {integrity: sha512-Yw0ECSpJoViF1qTU4DC6NwtC4aWGt1EkzaQB8KPPyCRR8z9TWeV0HbEFGTO+ZY1wB22zmxnJqhPyTpOVCpeHTA==} + micromatch@4.0.8: resolution: {integrity: sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==} engines: {node: '>=8.6'} @@ -1733,6 +1792,12 @@ packages: resolution: {integrity: sha512-VXJjc87FScF88uafS3JllDgvAm+c/Slfz06lorj2uAY34rlUu0Nt+v8wreiImcrgAjjIHp1rXpTDlLOGw29WwQ==} engines: {node: '>=18'} + oniguruma-parser@0.12.1: + resolution: {integrity: sha512-8Unqkvk1RYc6yq2WBYRj4hdnsAxVze8i7iPfQr8e4uSP3tRv0rpZcbGUDvxfQQcdwHt/e9PrMvGCsa8OqG9X3w==} + + oniguruma-to-es@4.3.4: + resolution: {integrity: sha512-3VhUGN3w2eYxnTzHn+ikMI+fp/96KoRSVK9/kMTcFqj1NRDh2IhQCKvYxDnWePKRXY/AqH+Fuiyb7VHSzBjHfA==} + optionator@0.9.4: resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==} engines: {node: '>= 0.8.0'} @@ -1882,6 +1947,9 @@ packages: resolution: {integrity: sha512-NxNv/kLguCA7p3jE8oL2aEBsrJWgAakBpgmgK6lpPWV+WuOmY6r2/zbAVnP+T8bQlA0nzHXSJSJW0Hq7ylaD2Q==} engines: {node: '>= 6'} + property-information@7.1.0: + resolution: {integrity: sha512-TwEZ+X+yCJmYfL7TPUOcvBZ4QfoT5YenQiJuX//0th53DE6w0xxLEtfK3iyryQFddXuvkIk51EEgrJQ0WJkOmQ==} + punycode.js@2.3.1: resolution: {integrity: sha512-uxFIHU0YlHYhDQtV4R9J6a52SLx28BCjT+4ieh7IGbgwVJWO+km431c4yRlREUAsAmt/uMjQUyQHNEPf0M39CA==} engines: {node: '>=6'} @@ -1907,6 +1975,15 @@ packages: resolution: {integrity: sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==} engines: {node: '>= 14.18.0'} + regex-recursion@6.0.2: + resolution: {integrity: sha512-0YCaSCq2VRIebiaUviZNs0cBz1kg5kVS2UKUfNIx8YVs1cN3AV7NTctO5FOKBA+UT2BPJIWZauYHPqJODG50cg==} + + regex-utilities@2.3.0: + resolution: {integrity: sha512-8VhliFJAWRaUiVvREIiW2NXXTmHs4vMNnSzuJVhscgmGav3g9VDxLrQndI3dZZVVdp0ZO/5v0xmX516/7M9cng==} + + regex@6.1.0: + resolution: {integrity: sha512-6VwtthbV4o/7+OaAF9I5L5V3llLEsoPyq9P1JVXkedTP33c7MfCG0/5NOPcSJn0TzXcG9YUrR0gQSWioew3LDg==} + require-from-string@2.0.2: resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==} engines: {node: '>=0.10.0'} @@ -1961,6 +2038,10 @@ packages: resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==} engines: {node: '>=8'} + shiki@4.0.2: + resolution: {integrity: sha512-eAVKTMedR5ckPo4xne/PjYQYrU3qx78gtJZ+sHlXEg5IHhhoQhMfZVzetTYuaJS0L2Ef3AcCRzCHV8T0WI6nIQ==} + engines: {node: '>=20'} + siginfo@2.0.0: resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==} @@ -1991,6 +2072,9 @@ packages: resolution: {integrity: sha512-i5uvt8C3ikiWeNZSVZNWcfZPItFQOsYTUAOkcUPGd8DqDy1uOUikjt5dG+uRlwyvR108Fb9DOd4GvXfT0N2/uQ==} engines: {node: '>= 12'} + space-separated-tokens@2.0.2: + resolution: {integrity: sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q==} + spawndamnit@3.0.1: resolution: {integrity: sha512-MmnduQUuHCoFckZoWnXsTg7JaiLBJrKFj9UI2MbRPGaJeVpsLcVBu6P/IGZovziM/YBsellCmsprgNA+w0CzVg==} @@ -2015,6 +2099,9 @@ packages: resolution: {integrity: sha512-Kxl3KJGb/gxkaUMOjRsQ8IrXiGW75O4E3RPjFIINOVH8AMl2SQ/yWdTzWwF3FevIX9LcMAjJW+GRwAlAbTSXdg==} engines: {node: '>=20'} + stringify-entities@4.0.4: + resolution: {integrity: sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg==} + strip-ansi@6.0.1: resolution: {integrity: sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==} engines: {node: '>=8'} @@ -2047,6 +2134,13 @@ packages: resolution: {integrity: sha512-MeQTA1r0litLUf0Rp/iisCaL8761lKAZHaimlbGK4j0HysC4PLfqygQj9srcs0m2RdtDYnF8UuYyKpbjHYp7Jw==} engines: {node: ^14.18.0 || >=16.0.0} + tailwindcss@4.2.1: + resolution: {integrity: sha512-/tBrSQ36vCleJkAOsy9kbNTgaxvGbyOamC30PRePTQe/o1MFwEKHQk4Cn7BNGaPtjp+PuUrByJehM1hgxfq4sw==} + + tapable@2.3.0: + resolution: {integrity: sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg==} + engines: {node: '>=6'} + term-size@2.2.1: resolution: {integrity: sha512-wK0Ri4fOGjv/XPy8SBHZChl8CM7uMc5VML7SqiQ0zG7+J5Vr+RMQDoHa2CNT6KHUnTGIXH34UDMkPzAUyapBZg==} engines: {node: '>=8'} @@ -2103,6 +2197,9 @@ packages: resolution: {integrity: sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==} hasBin: true + trim-lines@3.0.1: + resolution: {integrity: sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg==} + ts-api-utils@2.1.0: resolution: {integrity: sha512-CUgTZL1irw8u29bzrOD/nH85jqyc74D6SshFgujOIA7osm2Rz7dYH77agkx7H4FBNxDq7Cjf+IjaX/8zwFW+ZQ==} engines: {node: '>=18.12'} @@ -2174,6 +2271,21 @@ packages: undici-types@7.16.0: resolution: {integrity: sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==} + unist-util-is@6.0.1: + resolution: {integrity: sha512-LsiILbtBETkDz8I9p1dQ0uyRUWuaQzd/cuEeS1hoRSyW5E5XGmTzlwY1OrNzzakGowI9Dr/I8HVaw4hTtnxy8g==} + + unist-util-position@5.0.0: + resolution: {integrity: sha512-fucsC7HjXvkB5R3kTCO7kUjRdrS0BJt3M/FPxmHMBOm8JQi2BsHAHFsy27E0EolP8rp0NzXsJ+jNPyDWvOJZPA==} + + unist-util-stringify-position@4.0.0: + resolution: {integrity: sha512-0ASV06AAoKCDkS2+xw5RXJywruurpbC4JZSm7nr7MOt1ojAzvyyaO+UxZf18j8FCF6kmzCZKcAgN/yu2gm2XgQ==} + + unist-util-visit-parents@6.0.2: + resolution: {integrity: sha512-goh1s1TBrqSqukSc8wrjwWhL0hiJxgA8m4kFxGlQ+8FYQ3C/m11FcTs4YYem7V664AhHVvgoQLk890Ssdsr2IQ==} + + unist-util-visit@5.1.0: + resolution: {integrity: sha512-m+vIdyeCOpdr/QeQCu2EzxX/ohgS8KbnPDgFni4dQsfSCtpz8UqDyY5GjRru8PDKuYn7Fq19j1CQ+nJSsGKOzg==} + universalify@0.1.2: resolution: {integrity: sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg==} engines: {node: '>= 4.0.0'} @@ -2181,6 +2293,12 @@ packages: uri-js@4.4.1: resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==} + vfile-message@4.0.3: + resolution: {integrity: sha512-QTHzsGd1EhbZs4AsQ20JX1rC3cOlt/IWJruk893DfLRr57lcnOeMaWG4K0JrRta4mIJZKth2Au3mM3u03/JWKw==} + + vfile@6.0.3: + resolution: {integrity: sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q==} + vite@7.3.0: resolution: {integrity: sha512-dZwN5L1VlUBewiP6H9s2+B3e3Jg96D0vzN+Ry73sOefebhYr9f94wwkMNN/9ouoU8pV1BqA1d1zGk8928cx0rg==} engines: {node: ^20.19.0 || >=22.12.0} @@ -2321,6 +2439,9 @@ packages: resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==} engines: {node: '>=10'} + zwitch@2.0.4: + resolution: {integrity: sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==} + snapshots: '@acemir/cssom@0.9.30': {} @@ -2579,7 +2700,7 @@ snapshots: '@effect/vitest@0.27.0(effect@3.19.19)(vitest@4.0.16)': dependencies: effect: 3.19.19 - vitest: 4.0.16(@types/node@24.10.4)(@vitest/browser-playwright@4.0.16)(jiti@2.6.1)(jsdom@27.3.0)(lightningcss@1.30.2)(tsx@4.21.0)(yaml@2.8.2) + vitest: 4.0.16(@types/node@24.10.4)(@vitest/browser-playwright@4.0.16)(jiti@2.6.1)(jsdom@27.3.0)(lightningcss@1.31.1)(tsx@4.21.0)(yaml@2.8.2) '@esbuild/aix-ppc64@0.27.2': optional: true @@ -2756,6 +2877,11 @@ snapshots: '@jridgewell/sourcemap-codec': 1.5.5 '@jridgewell/trace-mapping': 0.3.31 + '@jridgewell/remapping@2.3.5': + dependencies: + '@jridgewell/gen-mapping': 0.3.13 + '@jridgewell/trace-mapping': 0.3.31 + '@jridgewell/resolve-uri@3.1.2': {} '@jridgewell/sourcemap-codec@1.5.5': {} @@ -2881,28 +3007,139 @@ snapshots: '@rollup/rollup-win32-x64-msvc@4.54.0': optional: true + '@shikijs/core@4.0.2': + dependencies: + '@shikijs/primitive': 4.0.2 + '@shikijs/types': 4.0.2 + '@shikijs/vscode-textmate': 10.0.2 + '@types/hast': 3.0.4 + hast-util-to-html: 9.0.5 + + '@shikijs/engine-javascript@4.0.2': + dependencies: + '@shikijs/types': 4.0.2 + '@shikijs/vscode-textmate': 10.0.2 + oniguruma-to-es: 4.3.4 + '@shikijs/engine-oniguruma@3.20.0': dependencies: '@shikijs/types': 3.20.0 '@shikijs/vscode-textmate': 10.0.2 + '@shikijs/engine-oniguruma@4.0.2': + dependencies: + '@shikijs/types': 4.0.2 + '@shikijs/vscode-textmate': 10.0.2 + '@shikijs/langs@3.20.0': dependencies: '@shikijs/types': 3.20.0 + '@shikijs/langs@4.0.2': + dependencies: + '@shikijs/types': 4.0.2 + + '@shikijs/markdown-it@4.0.2': + dependencies: + markdown-it: 14.1.1 + shiki: 4.0.2 + + '@shikijs/primitive@4.0.2': + dependencies: + '@shikijs/types': 4.0.2 + '@shikijs/vscode-textmate': 10.0.2 + '@types/hast': 3.0.4 + '@shikijs/themes@3.20.0': dependencies: '@shikijs/types': 3.20.0 + '@shikijs/themes@4.0.2': + dependencies: + '@shikijs/types': 4.0.2 + '@shikijs/types@3.20.0': dependencies: '@shikijs/vscode-textmate': 10.0.2 '@types/hast': 3.0.4 + '@shikijs/types@4.0.2': + dependencies: + '@shikijs/vscode-textmate': 10.0.2 + '@types/hast': 3.0.4 + '@shikijs/vscode-textmate@10.0.2': {} '@standard-schema/spec@1.1.0': {} + '@tailwindcss/node@4.2.1': + dependencies: + '@jridgewell/remapping': 2.3.5 + enhanced-resolve: 5.20.0 + jiti: 2.6.1 + lightningcss: 1.31.1 + magic-string: 0.30.21 + source-map-js: 1.2.1 + tailwindcss: 4.2.1 + + '@tailwindcss/oxide-android-arm64@4.2.1': + optional: true + + '@tailwindcss/oxide-darwin-arm64@4.2.1': + optional: true + + '@tailwindcss/oxide-darwin-x64@4.2.1': + optional: true + + '@tailwindcss/oxide-freebsd-x64@4.2.1': + optional: true + + '@tailwindcss/oxide-linux-arm-gnueabihf@4.2.1': + optional: true + + '@tailwindcss/oxide-linux-arm64-gnu@4.2.1': + optional: true + + '@tailwindcss/oxide-linux-arm64-musl@4.2.1': + optional: true + + '@tailwindcss/oxide-linux-x64-gnu@4.2.1': + optional: true + + '@tailwindcss/oxide-linux-x64-musl@4.2.1': + optional: true + + '@tailwindcss/oxide-wasm32-wasi@4.2.1': + optional: true + + '@tailwindcss/oxide-win32-arm64-msvc@4.2.1': + optional: true + + '@tailwindcss/oxide-win32-x64-msvc@4.2.1': + optional: true + + '@tailwindcss/oxide@4.2.1': + optionalDependencies: + '@tailwindcss/oxide-android-arm64': 4.2.1 + '@tailwindcss/oxide-darwin-arm64': 4.2.1 + '@tailwindcss/oxide-darwin-x64': 4.2.1 + '@tailwindcss/oxide-freebsd-x64': 4.2.1 + '@tailwindcss/oxide-linux-arm-gnueabihf': 4.2.1 + '@tailwindcss/oxide-linux-arm64-gnu': 4.2.1 + '@tailwindcss/oxide-linux-arm64-musl': 4.2.1 + '@tailwindcss/oxide-linux-x64-gnu': 4.2.1 + '@tailwindcss/oxide-linux-x64-musl': 4.2.1 + '@tailwindcss/oxide-wasm32-wasi': 4.2.1 + '@tailwindcss/oxide-win32-arm64-msvc': 4.2.1 + '@tailwindcss/oxide-win32-x64-msvc': 4.2.1 + + '@tailwindcss/vite@4.2.1(vite@7.3.0(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.31.1)(tsx@4.21.0)(yaml@2.8.2))': + dependencies: + '@tailwindcss/node': 4.2.1 + '@tailwindcss/oxide': 4.2.1 + tailwindcss: 4.2.1 + vite: 7.3.0(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.31.1)(tsx@4.21.0)(yaml@2.8.2) + '@types/chai@5.2.3': dependencies: '@types/deep-eql': 4.0.2 @@ -2925,6 +3162,10 @@ snapshots: '@types/linkify-it': 5.0.0 '@types/mdurl': 2.0.0 + '@types/mdast@4.0.4': + dependencies: + '@types/unist': 3.0.3 + '@types/mdurl@2.0.0': {} '@types/node@12.20.55': {} @@ -3031,29 +3272,31 @@ snapshots: '@typescript-eslint/types': 8.50.1 eslint-visitor-keys: 4.2.1 - '@vitest/browser-playwright@4.0.16(playwright@1.57.0)(vite@7.3.0(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)(yaml@2.8.2))(vitest@4.0.16)': + '@ungap/structured-clone@1.3.0': {} + + '@vitest/browser-playwright@4.0.16(playwright@1.57.0)(vite@7.3.0(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.31.1)(tsx@4.21.0)(yaml@2.8.2))(vitest@4.0.16)': dependencies: - '@vitest/browser': 4.0.16(vite@7.3.0(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)(yaml@2.8.2))(vitest@4.0.16) - '@vitest/mocker': 4.0.16(vite@7.3.0(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)(yaml@2.8.2)) + '@vitest/browser': 4.0.16(vite@7.3.0(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.31.1)(tsx@4.21.0)(yaml@2.8.2))(vitest@4.0.16) + '@vitest/mocker': 4.0.16(vite@7.3.0(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.31.1)(tsx@4.21.0)(yaml@2.8.2)) playwright: 1.57.0 tinyrainbow: 3.0.3 - vitest: 4.0.16(@types/node@24.10.4)(@vitest/browser-playwright@4.0.16)(jiti@2.6.1)(jsdom@27.3.0)(lightningcss@1.30.2)(tsx@4.21.0)(yaml@2.8.2) + vitest: 4.0.16(@types/node@24.10.4)(@vitest/browser-playwright@4.0.16)(jiti@2.6.1)(jsdom@27.3.0)(lightningcss@1.31.1)(tsx@4.21.0)(yaml@2.8.2) transitivePeerDependencies: - bufferutil - msw - utf-8-validate - vite - '@vitest/browser@4.0.16(vite@7.3.0(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)(yaml@2.8.2))(vitest@4.0.16)': + '@vitest/browser@4.0.16(vite@7.3.0(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.31.1)(tsx@4.21.0)(yaml@2.8.2))(vitest@4.0.16)': dependencies: - '@vitest/mocker': 4.0.16(vite@7.3.0(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)(yaml@2.8.2)) + '@vitest/mocker': 4.0.16(vite@7.3.0(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.31.1)(tsx@4.21.0)(yaml@2.8.2)) '@vitest/utils': 4.0.16 magic-string: 0.30.21 pixelmatch: 7.1.0 pngjs: 7.0.0 sirv: 3.0.2 tinyrainbow: 3.0.3 - vitest: 4.0.16(@types/node@24.10.4)(@vitest/browser-playwright@4.0.16)(jiti@2.6.1)(jsdom@27.3.0)(lightningcss@1.30.2)(tsx@4.21.0)(yaml@2.8.2) + vitest: 4.0.16(@types/node@24.10.4)(@vitest/browser-playwright@4.0.16)(jiti@2.6.1)(jsdom@27.3.0)(lightningcss@1.31.1)(tsx@4.21.0)(yaml@2.8.2) ws: 8.18.3 transitivePeerDependencies: - bufferutil @@ -3061,7 +3304,7 @@ snapshots: - utf-8-validate - vite - '@vitest/coverage-v8@4.0.16(@vitest/browser@4.0.16(vite@7.3.0(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)(yaml@2.8.2))(vitest@4.0.16))(vitest@4.0.16)': + '@vitest/coverage-v8@4.0.16(@vitest/browser@4.0.16(vite@7.3.0(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.31.1)(tsx@4.21.0)(yaml@2.8.2))(vitest@4.0.16))(vitest@4.0.16)': dependencies: '@bcoe/v8-coverage': 1.0.2 '@vitest/utils': 4.0.16 @@ -3074,9 +3317,9 @@ snapshots: obug: 2.1.1 std-env: 3.10.0 tinyrainbow: 3.0.3 - vitest: 4.0.16(@types/node@24.10.4)(@vitest/browser-playwright@4.0.16)(jiti@2.6.1)(jsdom@27.3.0)(lightningcss@1.30.2)(tsx@4.21.0)(yaml@2.8.2) + vitest: 4.0.16(@types/node@24.10.4)(@vitest/browser-playwright@4.0.16)(jiti@2.6.1)(jsdom@27.3.0)(lightningcss@1.31.1)(tsx@4.21.0)(yaml@2.8.2) optionalDependencies: - '@vitest/browser': 4.0.16(vite@7.3.0(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)(yaml@2.8.2))(vitest@4.0.16) + '@vitest/browser': 4.0.16(vite@7.3.0(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.31.1)(tsx@4.21.0)(yaml@2.8.2))(vitest@4.0.16) transitivePeerDependencies: - supports-color @@ -3089,13 +3332,13 @@ snapshots: chai: 6.2.2 tinyrainbow: 3.0.3 - '@vitest/mocker@4.0.16(vite@7.3.0(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)(yaml@2.8.2))': + '@vitest/mocker@4.0.16(vite@7.3.0(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.31.1)(tsx@4.21.0)(yaml@2.8.2))': dependencies: '@vitest/spy': 4.0.16 estree-walker: 3.0.3 magic-string: 0.30.21 optionalDependencies: - vite: 7.3.0(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)(yaml@2.8.2) + vite: 7.3.0(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.31.1)(tsx@4.21.0)(yaml@2.8.2) '@vitest/pretty-format@4.0.16': dependencies: @@ -3200,6 +3443,8 @@ snapshots: callsites@3.1.0: {} + ccount@2.0.1: {} + chai@6.2.2: {} chalk@4.1.2: @@ -3207,6 +3452,10 @@ snapshots: ansi-styles: 4.3.0 supports-color: 7.2.0 + character-entities-html4@2.1.0: {} + + character-entities-legacy@3.0.0: {} + chardet@2.1.1: {} chokidar@4.0.3: @@ -3232,6 +3481,8 @@ snapshots: colorette@2.0.20: {} + comma-separated-tokens@2.0.3: {} + commander@14.0.2: {} commander@4.1.1: {} @@ -3259,6 +3510,8 @@ snapshots: '@csstools/css-syntax-patches-for-csstree': 1.0.22 css-tree: 3.1.0 + daisyui@5.5.19: {} + data-urls@6.0.0: dependencies: whatwg-mimetype: 4.0.0 @@ -3272,10 +3525,15 @@ snapshots: deep-is@0.1.4: {} + dequal@2.0.3: {} + detect-indent@6.1.0: {} - detect-libc@2.1.2: - optional: true + detect-libc@2.1.2: {} + + devlop@1.1.0: + dependencies: + dequal: 2.0.3 dir-glob@3.0.1: dependencies: @@ -3293,6 +3551,11 @@ snapshots: emoji-regex@10.6.0: {} + enhanced-resolve@5.20.0: + dependencies: + graceful-fs: 4.2.11 + tapable: 2.3.0 + enquirer@2.4.1: dependencies: ansi-colors: 4.1.3 @@ -3541,12 +3804,32 @@ snapshots: has-flag@4.0.0: {} + hast-util-to-html@9.0.5: + dependencies: + '@types/hast': 3.0.4 + '@types/unist': 3.0.3 + ccount: 2.0.1 + comma-separated-tokens: 2.0.3 + hast-util-whitespace: 3.0.0 + html-void-elements: 3.0.0 + mdast-util-to-hast: 13.2.1 + property-information: 7.1.0 + space-separated-tokens: 2.0.2 + stringify-entities: 4.0.4 + zwitch: 2.0.4 + + hast-util-whitespace@3.0.0: + dependencies: + '@types/hast': 3.0.4 + html-encoding-sniffer@4.0.0: dependencies: whatwg-encoding: 3.1.1 html-escaper@2.0.2: {} + html-void-elements@3.0.0: {} + http-proxy-agent@7.0.2: dependencies: agent-base: 7.1.4 @@ -3627,8 +3910,7 @@ snapshots: html-escaper: 2.0.2 istanbul-lib-report: 3.0.1 - jiti@2.6.1: - optional: true + jiti@2.6.1: {} joycon@3.1.1: {} @@ -3695,55 +3977,54 @@ snapshots: prelude-ls: 1.2.1 type-check: 0.4.0 - lightningcss-android-arm64@1.30.2: + lightningcss-android-arm64@1.31.1: optional: true - lightningcss-darwin-arm64@1.30.2: + lightningcss-darwin-arm64@1.31.1: optional: true - lightningcss-darwin-x64@1.30.2: + lightningcss-darwin-x64@1.31.1: optional: true - lightningcss-freebsd-x64@1.30.2: + lightningcss-freebsd-x64@1.31.1: optional: true - lightningcss-linux-arm-gnueabihf@1.30.2: + lightningcss-linux-arm-gnueabihf@1.31.1: optional: true - lightningcss-linux-arm64-gnu@1.30.2: + lightningcss-linux-arm64-gnu@1.31.1: optional: true - lightningcss-linux-arm64-musl@1.30.2: + lightningcss-linux-arm64-musl@1.31.1: optional: true - lightningcss-linux-x64-gnu@1.30.2: + lightningcss-linux-x64-gnu@1.31.1: optional: true - lightningcss-linux-x64-musl@1.30.2: + lightningcss-linux-x64-musl@1.31.1: optional: true - lightningcss-win32-arm64-msvc@1.30.2: + lightningcss-win32-arm64-msvc@1.31.1: optional: true - lightningcss-win32-x64-msvc@1.30.2: + lightningcss-win32-x64-msvc@1.31.1: optional: true - lightningcss@1.30.2: + lightningcss@1.31.1: dependencies: detect-libc: 2.1.2 optionalDependencies: - lightningcss-android-arm64: 1.30.2 - lightningcss-darwin-arm64: 1.30.2 - lightningcss-darwin-x64: 1.30.2 - lightningcss-freebsd-x64: 1.30.2 - lightningcss-linux-arm-gnueabihf: 1.30.2 - lightningcss-linux-arm64-gnu: 1.30.2 - lightningcss-linux-arm64-musl: 1.30.2 - lightningcss-linux-x64-gnu: 1.30.2 - lightningcss-linux-x64-musl: 1.30.2 - lightningcss-win32-arm64-msvc: 1.30.2 - lightningcss-win32-x64-msvc: 1.30.2 - optional: true + lightningcss-android-arm64: 1.31.1 + lightningcss-darwin-arm64: 1.31.1 + lightningcss-darwin-x64: 1.31.1 + lightningcss-freebsd-x64: 1.31.1 + lightningcss-linux-arm-gnueabihf: 1.31.1 + lightningcss-linux-arm64-gnu: 1.31.1 + lightningcss-linux-arm64-musl: 1.31.1 + lightningcss-linux-x64-gnu: 1.31.1 + lightningcss-linux-x64-musl: 1.31.1 + lightningcss-win32-arm64-msvc: 1.31.1 + lightningcss-win32-x64-msvc: 1.31.1 lilconfig@3.1.3: {} @@ -3796,6 +4077,8 @@ snapshots: lru-cache@11.2.4: {} + lucide-static@0.576.0: {} + lunr@2.3.9: {} magic-string@0.30.21: @@ -3821,12 +4104,50 @@ snapshots: punycode.js: 2.3.1 uc.micro: 2.1.0 + markdown-it@14.1.1: + dependencies: + argparse: 2.0.1 + entities: 4.5.0 + linkify-it: 5.0.0 + mdurl: 2.0.0 + punycode.js: 2.3.1 + uc.micro: 2.1.0 + + mdast-util-to-hast@13.2.1: + dependencies: + '@types/hast': 3.0.4 + '@types/mdast': 4.0.4 + '@ungap/structured-clone': 1.3.0 + devlop: 1.1.0 + micromark-util-sanitize-uri: 2.0.1 + trim-lines: 3.0.1 + unist-util-position: 5.0.0 + unist-util-visit: 5.1.0 + vfile: 6.0.3 + mdn-data@2.12.2: {} mdurl@2.0.0: {} merge2@1.4.1: {} + micromark-util-character@2.1.1: + dependencies: + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-util-encode@2.0.1: {} + + micromark-util-sanitize-uri@2.0.1: + dependencies: + micromark-util-character: 2.1.1 + micromark-util-encode: 2.0.1 + micromark-util-symbol: 2.0.1 + + micromark-util-symbol@2.0.1: {} + + micromark-util-types@2.0.2: {} + micromatch@4.0.8: dependencies: braces: 3.0.3 @@ -3898,6 +4219,14 @@ snapshots: dependencies: mimic-function: 5.0.1 + oniguruma-parser@0.12.1: {} + + oniguruma-to-es@4.3.4: + dependencies: + oniguruma-parser: 0.12.1 + regex: 6.1.0 + regex-recursion: 6.0.2 + optionator@0.9.4: dependencies: deep-is: 0.1.4 @@ -4015,6 +4344,8 @@ snapshots: kleur: 3.0.3 sisteransi: 1.0.5 + property-information@7.1.0: {} + punycode.js@2.3.1: {} punycode@2.3.1: {} @@ -4034,6 +4365,16 @@ snapshots: readdirp@4.1.2: {} + regex-recursion@6.0.2: + dependencies: + regex-utilities: 2.3.0 + + regex-utilities@2.3.0: {} + + regex@6.1.0: + dependencies: + regex-utilities: 2.3.0 + require-from-string@2.0.2: {} resolve-from@4.0.0: {} @@ -4098,6 +4439,17 @@ snapshots: shebang-regex@3.0.0: {} + shiki@4.0.2: + dependencies: + '@shikijs/core': 4.0.2 + '@shikijs/engine-javascript': 4.0.2 + '@shikijs/engine-oniguruma': 4.0.2 + '@shikijs/langs': 4.0.2 + '@shikijs/themes': 4.0.2 + '@shikijs/types': 4.0.2 + '@shikijs/vscode-textmate': 10.0.2 + '@types/hast': 3.0.4 + siginfo@2.0.0: {} signal-exit@4.1.0: {} @@ -4121,6 +4473,8 @@ snapshots: source-map@0.7.6: {} + space-separated-tokens@2.0.2: {} + spawndamnit@3.0.1: dependencies: cross-spawn: 7.0.6 @@ -4145,6 +4499,11 @@ snapshots: get-east-asian-width: 1.4.0 strip-ansi: 7.1.2 + stringify-entities@4.0.4: + dependencies: + character-entities-html4: 2.1.0 + character-entities-legacy: 3.0.0 + strip-ansi@6.0.1: dependencies: ansi-regex: 5.0.1 @@ -4177,6 +4536,10 @@ snapshots: dependencies: '@pkgr/core': 0.2.9 + tailwindcss@4.2.1: {} + + tapable@2.3.0: {} + term-size@2.2.1: {} thenify-all@1.6.0: @@ -4222,6 +4585,8 @@ snapshots: tree-kill@1.2.2: {} + trim-lines@3.0.1: {} + ts-api-utils@2.1.0(typescript@5.9.3): dependencies: typescript: 5.9.3 @@ -4300,13 +4665,46 @@ snapshots: undici-types@7.16.0: {} + unist-util-is@6.0.1: + dependencies: + '@types/unist': 3.0.3 + + unist-util-position@5.0.0: + dependencies: + '@types/unist': 3.0.3 + + unist-util-stringify-position@4.0.0: + dependencies: + '@types/unist': 3.0.3 + + unist-util-visit-parents@6.0.2: + dependencies: + '@types/unist': 3.0.3 + unist-util-is: 6.0.1 + + unist-util-visit@5.1.0: + dependencies: + '@types/unist': 3.0.3 + unist-util-is: 6.0.1 + unist-util-visit-parents: 6.0.2 + universalify@0.1.2: {} uri-js@4.4.1: dependencies: punycode: 2.3.1 - vite@7.3.0(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)(yaml@2.8.2): + vfile-message@4.0.3: + dependencies: + '@types/unist': 3.0.3 + unist-util-stringify-position: 4.0.0 + + vfile@6.0.3: + dependencies: + '@types/unist': 3.0.3 + vfile-message: 4.0.3 + + vite@7.3.0(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.31.1)(tsx@4.21.0)(yaml@2.8.2): dependencies: esbuild: 0.27.2 fdir: 6.5.0(picomatch@4.0.3) @@ -4318,14 +4716,14 @@ snapshots: '@types/node': 24.10.4 fsevents: 2.3.3 jiti: 2.6.1 - lightningcss: 1.30.2 + lightningcss: 1.31.1 tsx: 4.21.0 yaml: 2.8.2 - vitest@4.0.16(@types/node@24.10.4)(@vitest/browser-playwright@4.0.16)(jiti@2.6.1)(jsdom@27.3.0)(lightningcss@1.30.2)(tsx@4.21.0)(yaml@2.8.2): + vitest@4.0.16(@types/node@24.10.4)(@vitest/browser-playwright@4.0.16)(jiti@2.6.1)(jsdom@27.3.0)(lightningcss@1.31.1)(tsx@4.21.0)(yaml@2.8.2): dependencies: '@vitest/expect': 4.0.16 - '@vitest/mocker': 4.0.16(vite@7.3.0(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)(yaml@2.8.2)) + '@vitest/mocker': 4.0.16(vite@7.3.0(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.31.1)(tsx@4.21.0)(yaml@2.8.2)) '@vitest/pretty-format': 4.0.16 '@vitest/runner': 4.0.16 '@vitest/snapshot': 4.0.16 @@ -4342,11 +4740,11 @@ snapshots: tinyexec: 1.0.2 tinyglobby: 0.2.15 tinyrainbow: 3.0.3 - vite: 7.3.0(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)(yaml@2.8.2) + vite: 7.3.0(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.31.1)(tsx@4.21.0)(yaml@2.8.2) why-is-node-running: 2.3.0 optionalDependencies: '@types/node': 24.10.4 - '@vitest/browser-playwright': 4.0.16(playwright@1.57.0)(vite@7.3.0(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)(yaml@2.8.2))(vitest@4.0.16) + '@vitest/browser-playwright': 4.0.16(playwright@1.57.0)(vite@7.3.0(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.31.1)(tsx@4.21.0)(yaml@2.8.2))(vitest@4.0.16) jsdom: 27.3.0 transitivePeerDependencies: - jiti @@ -4404,3 +4802,5 @@ snapshots: yaml@2.8.2: {} yocto-queue@0.1.0: {} + + zwitch@2.0.4: {} diff --git a/tsconfig.base.json b/tsconfig.base.json index 6b586bf8..de6a691c 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -17,7 +17,6 @@ "strict": true, "noUnusedLocals": true, "noUnusedParameters": true, - "erasableSyntaxOnly": true, "noFallthroughCasesInSwitch": true, "noUncheckedSideEffectImports": true } From 560cfae1a618cc91f59ee340d8357e6d3f49d4e2 Mon Sep 17 00:00:00 2001 From: Jon Laing Date: Fri, 27 Mar 2026 21:05:38 -0400 Subject: [PATCH 4/7] fixing api docs config --- package.json | 5 +---- typedoc.json | 1 - 2 files changed, 1 insertion(+), 5 deletions(-) diff --git a/package.json b/package.json index 3f37f4dc..a044b816 100644 --- a/package.json +++ b/package.json @@ -12,9 +12,7 @@ "url": "https://github.com/jonlaing/effex.git" }, "scripts": { - "dev": "pnpm -C demo dev", "build": "pnpm -r build", - "preview": "pnpm -C demo preview", "lint": "eslint .", "format": "prettier --log-level=silent --write .", "test": "vitest", @@ -24,7 +22,7 @@ "changeset": "changeset", "version": "changeset version", "release": "pnpm build && changeset publish", - "docgen": "rm -rf docs && typedoc", + "docs:gen": "rm -rf docs && typedoc", "prepare": "husky" }, "lint-staged": { @@ -50,7 +48,6 @@ "prettier": "^3.7.4", "tsup": "^8.5.1", "typedoc": "^0.28.15", - "typedoc-plugin-markdown": "^4.9.0", "typescript": "~5.9.3", "typescript-eslint": "^8.50.1", "vite": "^7.3.0", diff --git a/typedoc.json b/typedoc.json index fffa5db6..6a6216bc 100644 --- a/typedoc.json +++ b/typedoc.json @@ -9,7 +9,6 @@ "./packages/vite-plugin/src/index.ts" ], "out": "./docs/api", - "plugin": ["typedoc-plugin-markdown"], "excludePrivate": true, "excludeInternal": true, "hideGenerator": true, From 1d8925625b9b2060d1d7f9ae2473ca33bfd044ab Mon Sep 17 00:00:00 2001 From: Jon Laing Date: Sat, 28 Mar 2026 20:10:36 -0400 Subject: [PATCH 5/7] the docs site pretty much done --- .../content/01-migration/01-from-react.md | 466 +++++++++++++ .../content/01-migration/02-from-svelte.md | 639 ++++++++++++++++++ apps/docs/content/01-migration/03-from-vue.md | 523 ++++++++++++++ .../00-introduction.md | 0 .../01-getting-started.md | 2 - .../02-your-first-element.md | 2 - .../03-making-it-interactive.md | 2 - .../04-building-the-todo-list.md | 2 - .../05-toggling-and-updating.md | 2 - .../06-adding-new-todos.md | 2 - .../07-derived-state.md | 2 - .../08-conditional-rendering.md | 2 - .../09-deleting-todos.md | 2 - .../10-persistence.md | 2 - .../03-dom/00-elements-and-attributes.md | 127 ++++ apps/docs/content/03-dom/01-components.md | 141 ++++ apps/docs/content/03-dom/02-boundaries.md | 67 ++ .../content/03-dom/03-element-manipulation.md | 118 ++++ apps/docs/content/03-dom/04-animation.md | 145 ++++ apps/docs/content/03-dom/05-virtual-lists.md | 74 ++ apps/docs/content/03-dom/06-utilities.md | 97 +++ .../04-reactivity/00-signals-and-readables.md | 192 ++++++ .../content/04-reactivity/01-async-state.md | 246 +++++++ .../04-reactivity/02-reactive-collections.md | 178 +++++ .../04-reactivity/03-state-machines.md | 167 +++++ .../content/04-reactivity/04-control-flow.md | 159 +++++ .../content/05-router/00-defining-routes.md | 176 +++++ .../content/05-router/01-building-a-router.md | 191 ++++++ apps/docs/content/05-router/02-navigation.md | 202 ++++++ .../content/06-platform/00-introduction.md | 98 +++ .../06-platform/01-server-side-rendering.md | 200 ++++++ .../06-platform/02-static-site-generation.md | 175 +++++ .../content/06-platform/03-vite-plugin.md | 161 +++++ apps/docs/content/effect-in-2-minutes.md | 14 +- apps/docs/content/introduction.md | 104 +++ apps/docs/content/quick-start.md | 329 +++++++++ apps/docs/index.html | 4 +- apps/docs/package.json | 8 +- .../src/assets/GitHub_Invertocat_Black.svg | 10 + apps/docs/src/assets/effex-logo-dark.svg | 10 + apps/docs/src/assets/github.svg | 10 + apps/docs/src/client.ts | 9 +- apps/docs/src/components/DocToc.ts | 38 ++ apps/docs/src/components/Sidebar.ts | 88 +++ apps/docs/src/components/SidebarLayout.ts | 32 + apps/docs/src/content.server.ts | 212 ++++++ apps/docs/src/content.ts | 154 +---- apps/docs/src/entry.ts | 6 +- apps/docs/src/layout.ts | 2 +- apps/docs/src/pages/DocPage.ts | 100 +++ apps/docs/src/pages/HomePage.ts | 442 ++++++++++++ apps/docs/src/pages/NotFoundPage.ts | 22 + apps/docs/src/routes.ts | 150 ++-- apps/docs/src/styles.css | 367 ++++------ apps/docs/src/vite-env.d.ts | 1 + apps/docs/vite.config.ts | 6 +- effex-logo-dark.svg | 2 +- 57 files changed, 6188 insertions(+), 494 deletions(-) create mode 100644 apps/docs/content/01-migration/01-from-react.md create mode 100644 apps/docs/content/01-migration/02-from-svelte.md create mode 100644 apps/docs/content/01-migration/03-from-vue.md rename apps/docs/content/{todo-app => 02-todo-app}/00-introduction.md (100%) rename apps/docs/content/{todo-app => 02-todo-app}/01-getting-started.md (98%) rename apps/docs/content/{todo-app => 02-todo-app}/02-your-first-element.md (97%) rename apps/docs/content/{todo-app => 02-todo-app}/03-making-it-interactive.md (98%) rename apps/docs/content/{todo-app => 02-todo-app}/04-building-the-todo-list.md (97%) rename apps/docs/content/{todo-app => 02-todo-app}/05-toggling-and-updating.md (97%) rename apps/docs/content/{todo-app => 02-todo-app}/06-adding-new-todos.md (98%) rename apps/docs/content/{todo-app => 02-todo-app}/07-derived-state.md (97%) rename apps/docs/content/{todo-app => 02-todo-app}/08-conditional-rendering.md (98%) rename apps/docs/content/{todo-app => 02-todo-app}/09-deleting-todos.md (96%) rename apps/docs/content/{todo-app => 02-todo-app}/10-persistence.md (98%) create mode 100644 apps/docs/content/03-dom/00-elements-and-attributes.md create mode 100644 apps/docs/content/03-dom/01-components.md create mode 100644 apps/docs/content/03-dom/02-boundaries.md create mode 100644 apps/docs/content/03-dom/03-element-manipulation.md create mode 100644 apps/docs/content/03-dom/04-animation.md create mode 100644 apps/docs/content/03-dom/05-virtual-lists.md create mode 100644 apps/docs/content/03-dom/06-utilities.md create mode 100644 apps/docs/content/04-reactivity/00-signals-and-readables.md create mode 100644 apps/docs/content/04-reactivity/01-async-state.md create mode 100644 apps/docs/content/04-reactivity/02-reactive-collections.md create mode 100644 apps/docs/content/04-reactivity/03-state-machines.md create mode 100644 apps/docs/content/04-reactivity/04-control-flow.md create mode 100644 apps/docs/content/05-router/00-defining-routes.md create mode 100644 apps/docs/content/05-router/01-building-a-router.md create mode 100644 apps/docs/content/05-router/02-navigation.md create mode 100644 apps/docs/content/06-platform/00-introduction.md create mode 100644 apps/docs/content/06-platform/01-server-side-rendering.md create mode 100644 apps/docs/content/06-platform/02-static-site-generation.md create mode 100644 apps/docs/content/06-platform/03-vite-plugin.md create mode 100644 apps/docs/content/introduction.md create mode 100644 apps/docs/content/quick-start.md create mode 100644 apps/docs/src/assets/GitHub_Invertocat_Black.svg create mode 100644 apps/docs/src/assets/effex-logo-dark.svg create mode 100755 apps/docs/src/assets/github.svg create mode 100644 apps/docs/src/components/DocToc.ts create mode 100644 apps/docs/src/components/Sidebar.ts create mode 100644 apps/docs/src/components/SidebarLayout.ts create mode 100644 apps/docs/src/content.server.ts create mode 100644 apps/docs/src/pages/DocPage.ts create mode 100644 apps/docs/src/pages/HomePage.ts create mode 100644 apps/docs/src/pages/NotFoundPage.ts create mode 100644 apps/docs/src/vite-env.d.ts diff --git a/apps/docs/content/01-migration/01-from-react.md b/apps/docs/content/01-migration/01-from-react.md new file mode 100644 index 00000000..9b30ebb3 --- /dev/null +++ b/apps/docs/content/01-migration/01-from-react.md @@ -0,0 +1,466 @@ +--- +title: "Coming from React" +description: "A guide for React developers learning Effex — key differences, concept mapping, and side-by-side examples." +order: 1 +--- + +# Coming from React + +A guide for React developers learning Effex. This covers the key differences, concept mapping, and side-by-side examples to help you transition. + +## Why Switch? + +If you're already using [Effect](https://effect.website/) in your application, Effex lets you use the same patterns and mental model across your entire stack. No more context-switching between React's hooks model and Effect's compositional approach. + +### Typed Error Handling + +In React, component errors are runtime surprises. You catch them with error boundaries, but there's no compile-time visibility into what can fail. + +In Effex, every element has type `Element` where `E` is the error channel. Errors propagate through the component tree, and you **must** handle them before mounting: + +```ts +// This won't compile — UserProfile might fail with ApiError +mount(UserProfile(), document.body); // Type error! + +// Handle the error first +mount( + Boundary.error( + () => UserProfile(), + (error) => $.div({}, $.of(`Failed to load: ${error.message}`)), + ), + document.body, +); // Compiles +``` + +TypeScript tells you at build time which components can fail and forces you to handle it. + +### Fine-Grained Reactivity (No Virtual DOM) + +React re-renders entire component subtrees when state changes, then diffs a virtual DOM to find what actually changed. This works, but it's wasteful. + +Effex uses signals. When a signal updates, only the DOM nodes that actually depend on that signal update. No diffing, no wasted renders: + +```ts +// React: Changing count re-renders the entire component +function Counter() { + const [count, setCount] = useState(0); + console.log("render"); // Logs on every click + return

    ; +} + +// Effex: Only the text node updates +const Counter = () => + Effect.gen(function* () { + const count = yield* Signal.make(0); + console.log("render"); // Logs once, on mount + return yield* $.div({}, $.of(count)); // count changes update only this text + }); +``` + +### No Rules of Hooks + +React hooks have rules you must memorize: + +- Don't call hooks conditionally +- Exhaustive dependency arrays (with lint rules that don't always help) +- Stale closure bugs when you forget a dependency +- `useCallback` and `useMemo` everywhere for performance + +Effex has none of this. Create signals wherever you want. Use them wherever you want. The reactivity system tracks dependencies automatically: + +```ts +// React: Must memoize, manage deps, avoid stale closures +const [items, setItems] = useState([]); +const handleAdd = useCallback(() => { + setItems((prev) => [...prev, newItem]); // Must use prev, not items! +}, []); // Stale closure if you use items directly + +// Effex: Just write code +const items = yield* Signal.make([]); +const handleAdd = () => items.update((current) => [...current, newItem]); // Always fresh +``` + +### Automatic Resource Cleanup + +React's `useEffect` cleanup is manual and easy to get wrong. Forget to clean up a subscription? Memory leak. Return a non-function? Runtime error. + +Effex uses Effect's scope system. Resources are automatically cleaned up when components unmount: + +```ts +// React: Manual cleanup, easy to forget +useEffect(() => { + const subscription = eventSource.subscribe(handler); + return () => subscription.unsubscribe(); // Don't forget! +}, []); + +// Effex: Automatic cleanup via scope +yield* eventSource.pipe( + Stream.runForEach(handler), + Effect.forkIn(scope), // Cleaned up when scope closes +); +``` + +### No Re-render Cascades + +In React, when a parent re-renders, all children re-render too (unless wrapped in `React.memo`). This leads to prop drilling `memo` everywhere or using context for everything. + +In Effex, signal updates only notify actual subscribers. Parent "re-renders" don't exist: + +```ts +// React: Parent re-render causes child re-render +function Parent() { + const [count, setCount] = useState(0); // Child re-renders too! + return ; // Unless wrapped in memo() +} + +// Effex: Parent signal doesn't affect unrelated children +const Parent = () => + Effect.gen(function* () { + const count = yield* Signal.make(0); // Child doesn't care + return yield* $.div({}, Child()); // Child never "re-renders" + }); +``` + +### Better Async + +React's Suspense requires experimental features for data fetching, and error handling is separate from loading states. In Effex, it's unified: + +```ts +Boundary.suspense({ + render: () => + Effect.gen(function* () { + const user = yield* fetchUser(id); // Can fail! + return yield* UserProfile({ user }); + }), + fallback: () => $.div({}, $.of("Loading...")), + catch: (error) => $.div({}, $.of(`Error: ${error.message}`)), + delay: "200 millis", // Avoid loading flash +}); +``` + +## Concept Mapping + +| React | Effex | Notes | +|---|---|---| +| `useState(initial)` | `Signal.make(initial)` | Must `yield*` to create | +| `useMemo(() => x, deps)` | `Readable.map(dep, (v) => x)` | Auto-tracked, no dep arrays | +| `useEffect(() => {...}, deps)` | `Readable.tap(dep, fn)` | Automatic cleanup | +| `useCallback(fn, deps)` | Just use the function | No stale closures | +| `useContext(Ctx)` | `yield* ServiceTag` | Effect services | +| `useRef(initial)` | `ref()` | For DOM element refs | +| `` | `Component({ prop: x })` | Function calls | +| `{cond && }` | `when(cond, { onTrue: () => El(), onFalse: () => $.span() })` | Object config | +| `{x != null && }` | `matchOption(optX, { onSome: (x) => El({ x }), onNone: ... })` | Unwraps Option | +| `{arr.map(x => )}` | `each(arr, { key: x => x.id, render: x => El() })` | Key function, not prop | +| `` | `Boundary.error(try, catch)` | Typed errors! | +| `` | `Boundary.suspense({ render, fallback })` | With typed `catch` | +| Component re-render | Doesn't exist | Only signals update DOM | +| Virtual DOM diff | Doesn't exist | Direct DOM updates | +| `React.memo()` | Not needed | Fine-grained by default | + +## Side-by-Side Examples + +### State and Updates + +```tsx +// React +function Counter() { + const [count, setCount] = useState(0); + return ; +} + +// Effex +const Counter = () => + Effect.gen(function* () { + const count = yield* Signal.make(0); + return yield* $.button( + { onClick: () => count.update((c) => c + 1) }, + $.of(count), + ); + }); +``` + +### Derived State + +```tsx +// React +function Cart({ items }) { + const total = useMemo( + () => items.reduce((sum, i) => sum + i.price, 0), + [items], + ); + return
    Total: ${total}
    ; +} + +// Effex +const Cart = (props: { items: Readable.Readable }) => + Effect.gen(function* () { + const total = Readable.map(props.items, (items) => + items.reduce((sum, i) => sum + i.price, 0), + ); + return yield* $.div({}, t`Total: $${total}`); + }); +``` + +### Conditional Rendering + +```tsx +// React +function Auth({ isLoggedIn }) { + return isLoggedIn ? : ; +} + +// Effex +const Auth = (props: { isLoggedIn: Readable.Readable }) => + when(props.isLoggedIn, { + onTrue: () => Dashboard(), + onFalse: () => Login(), + }); +``` + +### Lists + +```tsx +// React +function TodoList({ todos }) { + return ( +
      + {todos.map((todo) => ( +
    • {todo.text}
    • + ))} +
    + ); +} + +// Effex +const TodoList = (props: { todos: Readable.Readable }) => + each(props.todos, { + container: () => $.ul(), + key: (todo) => todo.id, + render: (todo) => + $.li({}, $.of(Readable.map(todo, (t) => t.text))), + }); +``` + +### Data Fetching + +```tsx +// React (with Suspense + error boundary) +function UserProfile({ id }) { + const user = use(fetchUser(id)); // Experimental + return
    {user.name}
    ; +} +// Wrapped in error boundary + suspense elsewhere... + +// Effex — Option 1: Boundary.suspense (one-shot) +const UserProfile = (props: { id: string }) => + Boundary.suspense({ + render: () => + Effect.gen(function* () { + const user = yield* fetchUser(props.id); + return yield* $.div({}, $.of(user.name)); + }), + fallback: () => $.div({}, $.of("Loading...")), + catch: (e) => $.div({}, $.of(`Error: ${e}`)), + }); + +// Effex — Option 2: AsyncReadable (reactive, with refetch) +const UserProfileAsync = (props: { id: string }) => + Effect.gen(function* () { + const userData = yield* AsyncReadable.make(() => fetchUser(props.id)); + + return yield* $.div( + {}, + collect( + when(userData.isLoading, { + onTrue: () => $.div({}, $.of("Loading...")), + onFalse: () => $.span(), + }), + matchOption(userData.value, { + onSome: (user) => $.div({}, $.of(Readable.map(user, (u) => u.name))), + onNone: () => $.span(), + }), + matchOption(userData.error, { + onSome: (err) => $.div({ class: "error" }, $.of(Readable.map(err, (e) => e.message))), + onNone: () => $.span(), + }), + ), + ); + }); +``` + +### Context / Services + +```tsx +// React +const ThemeContext = createContext("light"); +function App() { + return ( + + + + ); +} +function Page() { + const theme = useContext(ThemeContext); + return
    ...
    ; +} + +// Effex +class ThemeService extends Context.Tag("Theme")() {} + +const Page = () => + Effect.gen(function* () { + const theme = yield* ThemeService; + return yield* $.div({ class: theme }, $.of("...")); + }); + +// Provide at mount +runApp(mount(Page().pipe(Effect.provideService(ThemeService, "dark")), root)); + +// Or provide inline +$.div( + { class: "app" }, + provide(ThemeService, "dark", Page()), +); +``` + +### Effects / Reactions + +```tsx +// React +function DocumentTitle({ title, unreadCount }) { + useEffect(() => { + document.title = unreadCount > 0 ? `(${unreadCount}) ${title}` : title; + }, [title, unreadCount]); + + useEffect(() => { + localStorage.setItem("lastTitle", title); + }, [title]); + + return

    {title}

    ; +} + +// Effex +const DocumentTitle = (props: { + title: Readable.Readable; + unreadCount: Readable.Readable; +}) => + Effect.gen(function* () { + // Runs whenever title or unreadCount changes + const combined = Readable.zipWith(props.title, props.unreadCount, (title, count) => + count > 0 ? `(${count}) ${title}` : title, + ); + yield* Readable.tap(combined, (t) => + Effect.sync(() => { document.title = t; }), + ); + + // Runs whenever title changes + yield* Readable.tap(props.title, (title) => + Effect.sync(() => localStorage.setItem("lastTitle", title)), + ); + + return yield* $.h1({}, $.of(props.title)); + }); +``` + +Key differences: +- **No dependency arrays to maintain** — `Readable.tap` subscribes to the readable it's given +- **No stale closure bugs** — Values are passed as parameters, not captured from scope +- **Automatic cleanup** — Subscriptions stop when the component unmounts + +## Key Mindset Shifts + +1. **Components don't re-render** — There's no render cycle. Signals update, and only their subscribers react. + +2. **Errors are values** — Instead of try/catch around everything, errors flow through the type system. Handle them explicitly with `Boundary.error` or Effect combinators. + +3. **Effects are explicit** — Side effects aren't hidden in `useEffect`. They're `Readable.tap` subscriptions or Effect values that you compose and run. + +4. **Cleanup is automatic** — Effect's scope system handles resource cleanup. No more forgotten unsubscribes. + +## Custom Equality + +In React, `useMemo` and `useEffect` use dependency arrays with shallow comparison, and there's no built-in way to customize equality. + +In Effex, equality is a first-class option on every reactive primitive: + +```ts +// Only trigger updates when the user ID changes, ignoring lastSeen timestamps +const currentUser = yield* Signal.make( + { id: 1, name: "Alice", lastSeen: new Date() }, + { equals: (a, b) => a.id === b.id }, +); +``` + +## Imperative DOM Access + +In React, you use `useRef` to get DOM element references: + +```tsx +// React +function FocusInput() { + const inputRef = useRef(null); + + const handleFocus = () => { + inputRef.current?.focus(); + inputRef.current?.scrollIntoView({ behavior: "smooth" }); + inputRef.current?.classList.add("focused"); + }; + + return ; +} +``` + +In Effex, `ref()` creates a pipeable element reference: + +```ts +// Effex +const FocusInput = () => + Effect.gen(function* () { + const inputRef = yield* ref(); + + const handleFocus = () => + inputRef.pipe( + Element.focus, + Element.scrollIntoView({ behavior: "smooth" }), + Element.addClass("focused"), + ); + + return yield* $.input({ ref: inputRef, onClick: handleFocus }); + }); +``` + +### Common React DOM Patterns + +| React Pattern | Effex Equivalent | +|---|---| +| `ref.current?.focus()` | `el.pipe(Element.focus)` | +| `ref.current?.blur()` | `el.pipe(Element.blur)` | +| `ref.current?.click()` | `el.pipe(Element.click)` | +| `ref.current?.scrollIntoView()` | `el.pipe(Element.scrollIntoView())` | +| `ref.current?.classList.add("x")` | `el.pipe(Element.addClass("x"))` | +| `ref.current?.classList.remove("x")` | `el.pipe(Element.removeClass("x"))` | +| `ref.current?.classList.toggle("x")` | `el.pipe(Element.toggleClass("x"))` | +| `ref.current?.setAttribute("k", "v")` | `el.pipe(Element.setAttribute("k", "v"))` | +| `ref.current?.removeAttribute("k")` | `el.pipe(Element.removeAttribute("k"))` | +| `ref.current?.dataset.state = "x"` | `el.pipe(Element.setData("state", "x"))` | +| `ref.current?.style.color = "red"` | `el.pipe(Element.setStyle("color", "red"))` | +| `ref.current?.querySelector(".x")` | `el.pipe(Element.querySelector(".x"))` | + +### Animation Hooks + +Effex's animation system passes elements to lifecycle hooks, letting you use Element helpers: + +```ts +when(isModalOpen, { + onTrue: () => Modal(), + onFalse: () => $.span(), + animate: { + enter: "fade-in", + exit: "fade-out", + onEnter: (el) => el.pipe(Element.focusFirst("[data-autofocus]")), + onBeforeExit: (el) => el.pipe(Element.blur), + }, +}); +``` diff --git a/apps/docs/content/01-migration/02-from-svelte.md b/apps/docs/content/01-migration/02-from-svelte.md new file mode 100644 index 00000000..24956067 --- /dev/null +++ b/apps/docs/content/01-migration/02-from-svelte.md @@ -0,0 +1,639 @@ +--- +title: "Coming from Svelte" +description: "A guide for Svelte developers learning Effex — key differences, concept mapping, and side-by-side examples." +order: 2 +--- + +# Coming from Svelte + +A guide for Svelte developers learning Effex. This covers the key differences, concept mapping, and side-by-side examples to help you transition. + +This guide covers both Svelte 4 (reactive statements, stores) and Svelte 5 (runes). + +## Why Switch? + +If you're already using [Effect](https://effect.website/) in your application, Effex lets you use the same patterns and mental model across your entire stack. No more context-switching between Svelte's compiler magic and Effect's compositional approach. + +### Typed Error Handling + +In Svelte, component errors are runtime surprises. There's no built-in error boundary mechanism, and you typically rely on try/catch in event handlers or global error handling. + +In Effex, every element has type `Element` where `E` is the error channel. Errors propagate through the component tree, and you **must** handle them before mounting: + +```ts +// This won't compile — UserProfile might fail with ApiError +mount(UserProfile(), document.body); // Type error! + +// Handle the error first +mount( + Boundary.error( + () => UserProfile(), + (error) => $.div({}, $.of(`Failed to load: ${error.message}`)), + ), + document.body, +); // Compiles +``` + +TypeScript tells you at build time which components can fail and forces you to handle it. + +### No Compiler Magic + +Svelte's power comes from its compiler — `$:` reactive statements, automatic subscriptions to stores, and runes in Svelte 5. This is elegant but opaque: + +```svelte + + + + + +``` + +Effex is explicit — what you write is what runs: + +```ts +// Effex: No transformation +const count = yield* Signal.make(0); +const doubled = Readable.map(count, (c) => c * 2); +``` + +Benefits: +- Easier to debug (no compiled output to understand) +- Standard TypeScript tooling works perfectly +- No Svelte-specific IDE plugins needed +- Behavior is predictable and inspectable + +### Similar Reactivity Model + +Both Svelte and Effex use fine-grained reactivity (not virtual DOM diffing). The concepts map fairly directly: + +| Svelte 5 Rune | Svelte 4 | Effex | +|---|---|---| +| `$state()` | `let x = ...` | `Signal.make()` | +| `$derived()` | `$: x = ...` | `Readable.map()` | +| `$effect()` | `$: { ... }` | `Readable.tap()` | +| `$props()` | `export let` | Function parameters | + +### Async Story + +```svelte + +{#await fetchUser(id)} +

    Loading...

    +{:then user} + +{:catch error} +

    Error: {error.message}

    +{/await} +``` + +```ts +// Effex — Option 1: Boundary.suspense (one-shot) +Boundary.suspense({ + render: () => + Effect.gen(function* () { + const user = yield* fetchUser(id); + return yield* UserProfile({ user }); + }), + fallback: () => $.div({}, $.of("Loading...")), + catch: (error) => $.div({}, $.of(`Error: ${error.message}`)), + delay: "200 millis", // Avoid loading flash — Svelte can't do this +}); + +// Effex — Option 2: AsyncReadable (reactive, with refetch) +const userData = yield* AsyncReadable.make(() => fetchUser(id)); + +// AsyncReadable has separate Readables for fine-grained reactivity +$.div( + {}, + collect( + when(userData.isLoading, { + onTrue: () => $.div({}, $.of("Loading...")), + onFalse: () => $.span(), + }), + matchOption(userData.value, { + onSome: (user) => UserProfile({ user }), + onNone: () => $.span(), + }), + matchOption(userData.error, { + onSome: (err) => $.div({ class: "error" }, $.of(Readable.map(err, (e) => e.message))), + onNone: () => $.span(), + }), + ), +); +``` + +The `delay` option on `Boundary.suspense` prevents flash of loading state for fast responses — something Svelte's `{#await}` can't do without manual work. `AsyncReadable` is better when you need refetch or reset capabilities. + +### Automatic Resource Cleanup + +Svelte's `onDestroy` requires manual cleanup registration. Effex uses Effect's scope system: + +```svelte + + +``` + +```ts +// Effex: Automatic cleanup via scope +yield* eventSource.pipe( + Stream.runForEach(handler), + Effect.forkIn(scope), // Cleaned up when scope closes +); +``` + +## Concept Mapping + +| Svelte 5 | Svelte 4 | Effex | Notes | +|---|---|---|---| +| `$state(initial)` | `let x = initial` | `Signal.make(initial)` | Must `yield*` to create | +| `$derived(expr)` | `$: x = expr` | `Readable.map(dep, fn)` | Derives from a readable | +| `$effect(() => {})` | `$: { statement }` | `Readable.tap(dep, fn)` | Automatic cleanup | +| `$props()` | `export let prop` | Function parameters | Plain TypeScript | +| `$bindable()` | `bind:value` | Signal + event handler | Explicit two-way binding | +| `getContext/setContext` | `getContext/setContext` | `yield* ServiceTag` | Effect services | +| `bind:this` | `bind:this` | `ref()` | For DOM element refs | +| `{#if} {:else}` | `{#if} {:else}` | `when(cond, { onTrue, onFalse })` | Object config | +| `{#if x != null}` | `{#if x != null}` | `matchOption(optX, { onSome, onNone })` | Unwraps Option | +| `{#each}` | `{#each}` | `each(arr, { key, render })` | Key function required | +| `{#await}` | `{#await}` | `Boundary.suspense` or `AsyncReadable` | Multiple options | +| `on:click` | `on:click` | `onClick` | Camel case handlers | +| `class:active={x}` | `class:active={x}` | `class` prop with Readable | Different syntax | +| `` | `` | Dynamic function call | Just call the component | +| `.svelte` files | `.svelte` files | Plain `.ts` files | No special file format | +| Stores (`writable`) | Stores | `Signal` | Similar concept | + +## Side-by-Side Examples + +### State and Updates + +```svelte + + + + + + + + + +``` + +```ts +// Effex +const Counter = () => + Effect.gen(function* () { + const count = yield* Signal.make(0); + return yield* $.button( + { onClick: () => count.update((c) => c + 1) }, + $.of(count), + ); + }); +``` + +### Derived State + +```svelte + + + +
    Total: ${total}
    + + + + +
    Total: ${total}
    +``` + +```ts +// Effex +const Cart = (props: { items: Readable.Readable }) => + Effect.gen(function* () { + const total = Readable.map(props.items, (items) => + items.reduce((sum, i) => sum + i.price, 0), + ); + return yield* $.div({}, t`Total: $${total}`); + }); +``` + +### Conditional Rendering + +```svelte + + + +{#if isLoggedIn} + +{:else} + +{/if} +``` + +```ts +// Effex +const Auth = (props: { isLoggedIn: Readable.Readable }) => + when(props.isLoggedIn, { + onTrue: () => Dashboard(), + onFalse: () => Login(), + }); +``` + +### Lists + +```svelte + + + +
      + {#each todos as todo (todo.id)} +
    • {todo.text}
    • + {/each} +
    +``` + +```ts +// Effex +const TodoList = (props: { todos: Readable.Readable }) => + each(props.todos, { + container: () => $.ul(), + key: (todo) => todo.id, + render: (todo) => + $.li({}, $.of(Readable.map(todo, (t) => t.text))), + }); +``` + +### Effects / Reactions + +```svelte + + + +

    {title}

    + + + + +

    {title}

    +``` + +```ts +// Effex +const DocumentTitle = (props: { + title: Readable.Readable; + unreadCount: Readable.Readable; +}) => + Effect.gen(function* () { + const combined = Readable.zipWith(props.title, props.unreadCount, (title, count) => + count > 0 ? `(${count}) ${title}` : title, + ); + yield* Readable.tap(combined, (t) => + Effect.sync(() => { document.title = t; }), + ); + + yield* Readable.tap(props.title, (title) => + Effect.sync(() => localStorage.setItem("lastTitle", title)), + ); + + return yield* $.h1({}, $.of(props.title)); + }); +``` + +### Context (Services) + +```svelte + + + + + + +
    ...
    +``` + +```ts +// Effex +class ThemeService extends Context.Tag("Theme")() {} + +const Page = () => + Effect.gen(function* () { + const theme = yield* ThemeService; + return yield* $.div({ class: theme }, $.of("...")); + }); + +// Provide at mount +runApp(mount(Page().pipe(Effect.provideService(ThemeService, "dark")), root)); + +// Or provide inline +$.div( + { class: "app" }, + provide(ThemeService, "dark", Page()), +); +``` + +### Two-Way Binding + +```svelte + + + + +

    You typed: {text}

    +``` + +```ts +// Effex +const TextInput = () => + Effect.gen(function* () { + const text = yield* Signal.make(""); + return yield* $.div( + {}, + collect( + $.input({ + value: text, + onInput: (e) => text.set((e.target as HTMLInputElement).value), + }), + $.p({}, t`You typed: ${text}`), + ), + ); + }); +``` + +### Stores (Svelte 4) + +```svelte + + + + +

    Doubled: {$doubled}

    +``` + +```ts +// Effex +const Counter = () => + Effect.gen(function* () { + const count = yield* Signal.make(0); + const doubled = Readable.map(count, (c) => c * 2); + + return yield* $.div( + {}, + collect( + $.button({ onClick: () => count.update((c) => c + 1) }, $.of(count)), + $.p({}, t`Doubled: ${doubled}`), + ), + ); + }); +``` + +### Slots / Children + +```svelte + + +

    Title

    +

    Card content

    +
    + + +
    + + +
    +``` + +```ts +// Effex +const Card = (props: { + header?: Element.Element; + children: Element.Element; +}) => + $.div( + { class: "card" }, + collect( + props.header ?? $.span(), + props.children, + ), + ); + +// Usage +Card({ + header: $.h1({}, $.of("Title")), + children: $.p({}, $.of("Card content")), +}); +``` + +### Async / Await Blocks + +```svelte + +{#await fetchUser(id)} +

    Loading...

    +{:then user} + +{:catch error} +

    Error: {error.message}

    +{/await} +``` + +```ts +// Effex +Boundary.suspense({ + render: () => + Effect.gen(function* () { + const user = yield* fetchUser(id); + return yield* UserProfile({ user }); + }), + fallback: () => $.p({}, $.of("Loading...")), + catch: (e) => $.p({}, $.of(`Error: ${e}`)), +}); +``` + +## Key Mindset Shifts + +1. **No compiler magic** — Svelte's `$:`, `$state`, `$derived` are compiler transforms. Effex is plain TypeScript — what you write is what runs. + +2. **Explicit sources** — Svelte auto-tracks dependencies through compilation. Effex's `Readable.map` and `Readable.tap` require explicit readables to derive from or subscribe to. + +3. **No special file format** — No `.svelte` files with ` + +{#if visible} +
    Fading content
    +{/if} +``` + +```ts +// Effex +when(visible, { + onTrue: () => $.div({}, $.of("Fading content")), + onFalse: () => $.span(), + animate: { + enter: "fade-in", // CSS class + exit: "fade-out", // CSS class + }, +}); +``` + +Effex's approach: +- Uses standard CSS animations (better performance, GPU-accelerated) +- Works with any CSS framework (Tailwind, etc.) +- Supports staggered list animations +- Respects `prefers-reduced-motion` by default + +## Imperative DOM Access + +In Svelte, you use `bind:this` to get DOM element references: + +```svelte + + + + +``` + +In Effex, `ref()` creates a pipeable element reference: + +```ts +// Effex +const FocusInput = () => + Effect.gen(function* () { + const inputRef = yield* ref(); + + const handleFocus = () => + inputRef.pipe( + Element.focus, + Element.scrollIntoView({ behavior: "smooth" }), + Element.addClass("focused"), + ); + + return yield* $.input({ ref: inputRef, onClick: handleFocus }); + }); +``` + +### Common Svelte DOM Patterns + +| Svelte Pattern | Effex Equivalent | +|---|---| +| `el?.focus()` | `el.pipe(Element.focus)` | +| `el?.blur()` | `el.pipe(Element.blur)` | +| `el?.click()` | `el.pipe(Element.click)` | +| `el?.scrollIntoView()` | `el.pipe(Element.scrollIntoView())` | +| `el?.classList.add("x")` | `el.pipe(Element.addClass("x"))` | +| `el?.classList.remove("x")` | `el.pipe(Element.removeClass("x"))` | +| `el?.classList.toggle("x")` | `el.pipe(Element.toggleClass("x"))` | +| `el?.setAttribute("k", "v")` | `el.pipe(Element.setAttribute("k", "v"))` | +| `el?.dataset.state = "x"` | `el.pipe(Element.setData("state", "x"))` | +| `el?.style.color = "red"` | `el.pipe(Element.setStyle("color", "red"))` | +| `el?.querySelector(".x")` | `el.pipe(Element.querySelector(".x"))` | + +### Animation Hooks with Element Helpers + +Effex's animation system passes elements to lifecycle hooks, letting you use Element helpers: + +```ts +when(isModalOpen, { + onTrue: () => Modal(), + onFalse: () => $.span(), + animate: { + enter: "fade-in", + exit: "fade-out", + onEnter: (el) => el.pipe(Element.focusFirst("[data-autofocus]")), + onBeforeExit: (el) => el.pipe(Element.blur), + }, +}); +``` + +This is similar to Svelte's `in:`, `out:` transition directive hooks but uses pipeable operations for composability. diff --git a/apps/docs/content/01-migration/03-from-vue.md b/apps/docs/content/01-migration/03-from-vue.md new file mode 100644 index 00000000..21541a6c --- /dev/null +++ b/apps/docs/content/01-migration/03-from-vue.md @@ -0,0 +1,523 @@ +--- +title: "Coming from Vue" +description: "A guide for Vue developers learning Effex — key differences, concept mapping, and side-by-side examples." +order: 3 +--- + +# Coming from Vue + +A guide for Vue developers learning Effex. This covers the key differences, concept mapping, and side-by-side examples to help you transition. + +## Why Switch? + +If you're already using [Effect](https://effect.website/) in your application, Effex lets you use the same patterns and mental model across your entire stack. No more context-switching between Vue's reactivity model and Effect's compositional approach. + +### Typed Error Handling + +In Vue, component errors are runtime surprises. You catch them with `errorCaptured` hooks or global error handlers, but there's no compile-time visibility into what can fail. + +In Effex, every element has type `Element` where `E` is the error channel. Errors propagate through the component tree, and you **must** handle them before mounting: + +```ts +// This won't compile — UserProfile might fail with ApiError +mount(UserProfile(), document.body); // Type error! + +// Handle the error first +mount( + Boundary.error( + () => UserProfile(), + (error) => $.div({}, $.of(`Failed to load: ${error.message}`)), + ), + document.body, +); // Compiles +``` + +TypeScript tells you at build time which components can fail and forces you to handle it. + +### Similar Reactivity, Different Execution + +Vue's Composition API and Effex share similar reactive concepts — both have signals (refs) and derived values (computed). The key difference is *when* things run: + +- Vue: Template re-renders when refs change, computed values update lazily +- Effex: DOM nodes subscribe directly to signals, updates are synchronous and targeted + +```ts +// Vue: Computed re-evaluates, template re-renders +const count = ref(0); +const doubled = computed(() => count.value * 2); +// Template: {{ doubled }} — entire template function runs + +// Effex: Only the text node updates +const count = yield* Signal.make(0); +const doubled = Readable.map(count, (c) => c * 2); +// $.span({}, $.of(doubled)) — only this span's text updates +``` + +### No Template Compilation + +Vue uses a custom template syntax that compiles to render functions. Effex uses plain TypeScript function calls: + +```ts +// Vue template + + +// Effex +$.div( + { class: "card" }, + collect( + $.h1({}, $.of(title)), + $.button({ onClick: handleClick }, $.of("Submit")), + ), +) +``` + +Benefits: +- Full TypeScript inference everywhere +- No build step required for templates +- Easier to debug (no compiled output to trace through) +- IDE features work perfectly (rename, find references, etc.) + +### Automatic Resource Cleanup + +Vue's `onUnmounted` and `watchEffect` cleanup are manual. Effex uses Effect's scope system — resources are automatically cleaned up when components unmount: + +```ts +// Vue: Manual cleanup registration +onMounted(() => { + const subscription = eventSource.subscribe(handler); + onUnmounted(() => subscription.unsubscribe()); +}); + +// Effex: Automatic cleanup via scope +yield* eventSource.pipe( + Stream.runForEach(handler), + Effect.forkIn(scope), // Cleaned up when scope closes +); +``` + +### Better Async Integration + +Vue's `` is limited and doesn't integrate well with error handling. Effex has two approaches: + +```ts +// Option 1: Boundary.suspense (one-shot) +Boundary.suspense({ + render: () => + Effect.gen(function* () { + const user = yield* fetchUser(id); // Can fail! + return yield* UserProfile({ user }); + }), + fallback: () => $.div({}, $.of("Loading...")), + catch: (error) => $.div({}, $.of(`Error: ${error.message}`)), + delay: "200 millis", // Avoid loading flash +}); + +// Option 2: AsyncReadable (reactive, with refetch) +const userData = yield* AsyncReadable.make(() => fetchUser(id)); + +// AsyncReadable has separate Readables for fine-grained reactivity +$.div( + {}, + collect( + when(userData.isLoading, { + onTrue: () => $.div({}, $.of("Loading...")), + onFalse: () => $.span(), + }), + matchOption(userData.value, { + onSome: (user) => UserProfile({ user }), + onNone: () => $.span(), + }), + matchOption(userData.error, { + onSome: (err) => $.div({ class: "error" }, $.of(Readable.map(err, (e) => e.message))), + onNone: () => $.span(), + }), + ), +); +``` + +## Concept Mapping + +| Vue (Composition API) | Effex | Notes | +|---|---|---| +| `ref(initial)` | `Signal.make(initial)` | Must `yield*` to create | +| `reactive(obj)` | `Signal.make(obj)` | Same as ref for objects | +| `computed(() => x)` | `Readable.map(dep, fn)` | Derives from a readable | +| `watch(source, cb)` | `Readable.tap(source, fn)` | Automatic cleanup | +| `watchEffect(cb)` | `Readable.tap(source, fn)` | Explicit source | +| `provide/inject` | `yield* ServiceTag` | Effect services | +| `ref` (template ref) | `ref()` | For DOM element refs | +| `v-if / v-else` | `when(cond, { onTrue, onFalse })` | Object config | +| `v-if="x != null"` | `matchOption(optX, { onSome, onNone })` | Unwraps Option | +| `v-show` | Signal-based class/style | No direct equivalent | +| `v-for` | `each(arr, { key, render })` | Key function, not `:key` | +| `@click` / `v-on` | `onClick` / event props | Camel case handlers | +| `:class` / `v-bind:class` | `class` prop with Readable | Reactive by default | +| `` | `Portal()` | Similar API | +| `` | `Boundary.suspense` or `AsyncReadable` | Multiple options | +| `defineProps` | Function parameters | Plain TypeScript | +| `defineEmits` | Callback props | Plain functions | +| SFC `.vue` files | Plain `.ts` files | No special file format | + +## Side-by-Side Examples + +### State and Updates + +```vue + + + + +``` + +```ts +// Effex +const Counter = () => + Effect.gen(function* () { + const count = yield* Signal.make(0); + return yield* $.button( + { onClick: () => count.update((c) => c + 1) }, + $.of(count), + ); + }); +``` + +### Computed / Derived State + +```vue + + + + +``` + +```ts +// Effex +const Cart = (props: { items: Readable.Readable }) => + Effect.gen(function* () { + const total = Readable.map(props.items, (items) => + items.reduce((sum, i) => sum + i.price, 0), + ); + return yield* $.div({}, t`Total: $${total}`); + }); +``` + +### Conditional Rendering + +```vue + + + + +``` + +```ts +// Effex +const Auth = (props: { isLoggedIn: Readable.Readable }) => + when(props.isLoggedIn, { + onTrue: () => Dashboard(), + onFalse: () => Login(), + }); +``` + +### Lists + +```vue + + + + +``` + +```ts +// Effex +const TodoList = (props: { todos: Readable.Readable }) => + each(props.todos, { + container: () => $.ul(), + key: (todo) => todo.id, + render: (todo) => + $.li({}, $.of(Readable.map(todo, (t) => t.text))), + }); +``` + +### Watchers / Reactions + +```vue + + + + +``` + +```ts +// Effex +const DocumentTitle = (props: { + title: Readable.Readable; + unreadCount: Readable.Readable; +}) => + Effect.gen(function* () { + const combined = Readable.zipWith(props.title, props.unreadCount, (title, count) => + count > 0 ? `(${count}) ${title}` : title, + ); + yield* Readable.tap(combined, (t) => + Effect.sync(() => { document.title = t; }), + ); + + yield* Readable.tap(props.title, (title) => + Effect.sync(() => localStorage.setItem("lastTitle", title)), + ); + + return yield* $.h1({}, $.of(props.title)); + }); +``` + +### Provide / Inject (Services) + +```vue + + + + + + + +``` + +```ts +// Effex +class ThemeService extends Context.Tag("Theme")() {} + +const Page = () => + Effect.gen(function* () { + const theme = yield* ThemeService; + return yield* $.div({ class: theme }, $.of("...")); + }); + +// Provide at mount +runApp(mount(Page().pipe(Effect.provideService(ThemeService, "dark")), root)); + +// Or provide inline +$.div( + { class: "app" }, + provide(ThemeService, "dark", Page()), +); +``` + +### Two-Way Binding (v-model) + +```vue + + + + +``` + +```ts +// Effex +const TextInput = () => + Effect.gen(function* () { + const text = yield* Signal.make(""); + return yield* $.div( + {}, + collect( + $.input({ + value: text, + onInput: (e) => text.set((e.target as HTMLInputElement).value), + }), + $.p({}, t`You typed: ${text}`), + ), + ); + }); +``` + +### Teleport / Portal + +```vue + + +``` + +```ts +// Effex +const Modal = () => + Portal(() => + $.div({ class: "modal" }, $.of("Modal content")), + ); + +// Or with a specific target +Portal({ target: "#modal-root" }, () => + $.div({ class: "modal" }, $.of("Modal content")), +); +``` + +## Key Mindset Shifts + +1. **No template syntax** — Everything is TypeScript. `v-if` becomes `when()`, `v-for` becomes `each()`, `@click` becomes `onClick`. + +2. **Explicit sources** — Vue's `watchEffect` auto-tracks. Effex's `Readable.tap` requires an explicit readable to subscribe to. + +3. **Errors are values** — Instead of `errorCaptured` hooks, errors flow through the type system. Handle them explicitly with `Boundary.error`. + +4. **Effects are explicit** — Side effects aren't hidden in `watchEffect`. They're `Readable.tap` subscriptions that you set up explicitly. + +5. **No SFC magic** — No ` + + +``` + +In Effex, `ref()` creates a pipeable element reference: + +```ts +// Effex +const FocusInput = () => + Effect.gen(function* () { + const inputRef = yield* ref(); + + const handleFocus = () => + inputRef.pipe( + Element.focus, + Element.scrollIntoView({ behavior: "smooth" }), + Element.addClass("focused"), + ); + + return yield* $.input({ ref: inputRef, onClick: handleFocus }); + }); +``` + +### Common Vue DOM Patterns + +| Vue Pattern | Effex Equivalent | +|---|---| +| `ref.value?.focus()` | `el.pipe(Element.focus)` | +| `ref.value?.blur()` | `el.pipe(Element.blur)` | +| `ref.value?.click()` | `el.pipe(Element.click)` | +| `ref.value?.scrollIntoView()` | `el.pipe(Element.scrollIntoView())` | +| `ref.value?.classList.add("x")` | `el.pipe(Element.addClass("x"))` | +| `ref.value?.classList.remove("x")` | `el.pipe(Element.removeClass("x"))` | +| `ref.value?.classList.toggle("x")` | `el.pipe(Element.toggleClass("x"))` | +| `ref.value?.setAttribute("k", "v")` | `el.pipe(Element.setAttribute("k", "v"))` | +| `ref.value?.dataset.state = "x"` | `el.pipe(Element.setData("state", "x"))` | +| `ref.value?.style.color = "red"` | `el.pipe(Element.setStyle("color", "red"))` | +| `ref.value?.querySelector(".x")` | `el.pipe(Element.querySelector(".x"))` | + +### Animation Hooks + +Effex's animation system passes elements to lifecycle hooks, letting you use Element helpers: + +```ts +when(isModalOpen, { + onTrue: () => Modal(), + onFalse: () => $.span(), + animate: { + enter: "fade-in", + exit: "fade-out", + onEnter: (el) => el.pipe(Element.focusFirst("[data-autofocus]")), + onBeforeExit: (el) => el.pipe(Element.blur), + }, +}); +``` + +This is similar to Vue's `` hooks but with pipeable operations instead of imperative code. diff --git a/apps/docs/content/todo-app/00-introduction.md b/apps/docs/content/02-todo-app/00-introduction.md similarity index 100% rename from apps/docs/content/todo-app/00-introduction.md rename to apps/docs/content/02-todo-app/00-introduction.md diff --git a/apps/docs/content/todo-app/01-getting-started.md b/apps/docs/content/02-todo-app/01-getting-started.md similarity index 98% rename from apps/docs/content/todo-app/01-getting-started.md rename to apps/docs/content/02-todo-app/01-getting-started.md index 380b2afa..1a5d2bc9 100644 --- a/apps/docs/content/todo-app/01-getting-started.md +++ b/apps/docs/content/02-todo-app/01-getting-started.md @@ -113,5 +113,3 @@ The `$` factory returns an Effect that, when run, creates a DOM element. Effects ## Next Steps You've got a working Effex app! In the next chapter, we'll explore the `$` factory in depth and build out the structure of our todo app. - -[Next: Your First Element →](./02-your-first-element.md) diff --git a/apps/docs/content/todo-app/02-your-first-element.md b/apps/docs/content/02-todo-app/02-your-first-element.md similarity index 97% rename from apps/docs/content/todo-app/02-your-first-element.md rename to apps/docs/content/02-todo-app/02-your-first-element.md index 07da66a5..bf2de05a 100644 --- a/apps/docs/content/todo-app/02-your-first-element.md +++ b/apps/docs/content/02-todo-app/02-your-first-element.md @@ -239,5 +239,3 @@ But it's all static! In the next chapter, we'll add reactivity with Signals to m 3. Use **`collect(...)`** to pass multiple children 4. Use **`$.of("text")`** for text content 5. Elements **nest naturally** - just put elements inside elements - -[← Previous: Getting Started](./01-getting-started.md) | [Next: Making It Interactive →](./03-making-it-interactive.md) diff --git a/apps/docs/content/todo-app/03-making-it-interactive.md b/apps/docs/content/02-todo-app/03-making-it-interactive.md similarity index 98% rename from apps/docs/content/todo-app/03-making-it-interactive.md rename to apps/docs/content/02-todo-app/03-making-it-interactive.md index 1ce14ae8..82909997 100644 --- a/apps/docs/content/todo-app/03-making-it-interactive.md +++ b/apps/docs/content/02-todo-app/03-making-it-interactive.md @@ -211,5 +211,3 @@ This is **fine-grained reactivity**. We don't re-render the whole app. Only the ## Cleanup Remove the test button before the next chapter. We'll add proper todo creation soon. - -[← Previous: Your First Element](./02-your-first-element.md) | [Next: Building the Todo List →](./04-building-the-todo-list.md) diff --git a/apps/docs/content/todo-app/04-building-the-todo-list.md b/apps/docs/content/02-todo-app/04-building-the-todo-list.md similarity index 97% rename from apps/docs/content/todo-app/04-building-the-todo-list.md rename to apps/docs/content/02-todo-app/04-building-the-todo-list.md index 2770e494..44f0e88c 100644 --- a/apps/docs/content/todo-app/04-building-the-todo-list.md +++ b/apps/docs/content/02-todo-app/04-building-the-todo-list.md @@ -241,5 +241,3 @@ const MyComponent = (props: MyComponentProps) => ## Cleanup Remove the test buttons before the next chapter. - -[← Previous: Making It Interactive](./03-making-it-interactive.md) | [Next: Toggling and Updating →](./05-toggling-and-updating.md) diff --git a/apps/docs/content/todo-app/05-toggling-and-updating.md b/apps/docs/content/02-todo-app/05-toggling-and-updating.md similarity index 97% rename from apps/docs/content/todo-app/05-toggling-and-updating.md rename to apps/docs/content/02-todo-app/05-toggling-and-updating.md index 04559560..ac71db99 100644 --- a/apps/docs/content/todo-app/05-toggling-and-updating.md +++ b/apps/docs/content/02-todo-app/05-toggling-and-updating.md @@ -215,5 +215,3 @@ Now the count updates as you toggle todos! 3. **Conditional classes** work with `Readable.map()` returning different strings 4. **Event handlers** return Effects 5. Updates are **fine-grained**—only changed parts of the DOM update - -[← Previous: Building the Todo List](./04-building-the-todo-list.md) | [Next: Adding New Todos →](./06-adding-new-todos.md) diff --git a/apps/docs/content/todo-app/06-adding-new-todos.md b/apps/docs/content/02-todo-app/06-adding-new-todos.md similarity index 98% rename from apps/docs/content/todo-app/06-adding-new-todos.md rename to apps/docs/content/02-todo-app/06-adding-new-todos.md index e2541b89..fc289b15 100644 --- a/apps/docs/content/todo-app/06-adding-new-todos.md +++ b/apps/docs/content/02-todo-app/06-adding-new-todos.md @@ -228,5 +228,3 @@ runApp(mount(yield* App(), container)); 2. **Signal operations** (`.get`, `.set()`, `.update()`) return Effects — use `yield*` in `Effect.gen` 3. **Event handlers** return Effects (use `Effect.void` for no-ops) 4. New items appear **automatically** thanks to `each` and reactivity - -[← Previous: Toggling and Updating](./05-toggling-and-updating.md) | [Next: Derived State →](./07-derived-state.md) diff --git a/apps/docs/content/todo-app/07-derived-state.md b/apps/docs/content/02-todo-app/07-derived-state.md similarity index 97% rename from apps/docs/content/todo-app/07-derived-state.md rename to apps/docs/content/02-todo-app/07-derived-state.md index 95a4c871..08fecfd9 100644 --- a/apps/docs/content/todo-app/07-derived-state.md +++ b/apps/docs/content/02-todo-app/07-derived-state.md @@ -235,5 +235,3 @@ The key difference: Effex derivations are explicit and type-safe. You always kno 3. Derived values **update automatically** when sources change 4. Derived values are **read-only** 5. Use derived state for **computed/filtered views** of your data - -[← Previous: Adding New Todos](./06-adding-new-todos.md) | [Next: Conditional Rendering →](./08-conditional-rendering.md) diff --git a/apps/docs/content/todo-app/08-conditional-rendering.md b/apps/docs/content/02-todo-app/08-conditional-rendering.md similarity index 98% rename from apps/docs/content/todo-app/08-conditional-rendering.md rename to apps/docs/content/02-todo-app/08-conditional-rendering.md index 7b4053c3..25c3fbac 100644 --- a/apps/docs/content/todo-app/08-conditional-rendering.md +++ b/apps/docs/content/02-todo-app/08-conditional-rendering.md @@ -245,5 +245,3 @@ We won't cover animations in depth here, but know that Effex supports CSS-based 3. **`onTrue`** renders when true, **`onFalse`** when false 4. Elements are **added/removed from DOM**, not just hidden 5. Use `when` for **presence**, CSS for **visibility** - -[← Previous: Derived State](./07-derived-state.md) | [Next: Deleting Todos →](./09-deleting-todos.md) diff --git a/apps/docs/content/todo-app/09-deleting-todos.md b/apps/docs/content/02-todo-app/09-deleting-todos.md similarity index 96% rename from apps/docs/content/todo-app/09-deleting-todos.md rename to apps/docs/content/02-todo-app/09-deleting-todos.md index 6a0eed5d..a1bb4385 100644 --- a/apps/docs/content/todo-app/09-deleting-todos.md +++ b/apps/docs/content/02-todo-app/09-deleting-todos.md @@ -149,5 +149,3 @@ The flow is simple: 2. **Pass handlers down** just like toggle 3. **CSS hover states** work great for revealing actions 4. The **DOM updates automatically** when items are removed - -[← Previous: Conditional Rendering](./08-conditional-rendering.md) | [Next: Persistence →](./10-persistence.md) diff --git a/apps/docs/content/todo-app/10-persistence.md b/apps/docs/content/02-todo-app/10-persistence.md similarity index 98% rename from apps/docs/content/todo-app/10-persistence.md rename to apps/docs/content/02-todo-app/10-persistence.md index b5c5bb4f..11471cac 100644 --- a/apps/docs/content/todo-app/10-persistence.md +++ b/apps/docs/content/02-todo-app/10-persistence.md @@ -310,5 +310,3 @@ You've built a complete todo application with Effex! You learned: - **[API Reference](/docs/api)** - Complete API documentation Happy building! - -[← Previous: Deleting Todos](./09-deleting-todos.md) | [Back to Introduction](./00-introduction.md) diff --git a/apps/docs/content/03-dom/00-elements-and-attributes.md b/apps/docs/content/03-dom/00-elements-and-attributes.md new file mode 100644 index 00000000..fad89493 --- /dev/null +++ b/apps/docs/content/03-dom/00-elements-and-attributes.md @@ -0,0 +1,127 @@ +--- +title: "Elements & Attributes" +description: "Create HTML and SVG elements, set attributes, bind reactive values, and handle events." +order: 0 +--- + +# Elements & Attributes + +The `$` namespace is your entry point for creating DOM elements. Every element factory returns an Effect that produces a real DOM node — no virtual DOM, no diffing. + +## Creating Elements + +```typescript +import { $, collect } from "@effex/dom"; + +yield* $.div({ class: "container" }, collect( + $.h1({}, $.of("Hello")), + $.p({}, $.of("Welcome to Effex")), +)); +``` + +Every HTML and SVG element has a corresponding factory on `$`: `$.div`, `$.span`, `$.button`, `$.input`, `$.svg`, `$.path`, and so on. + +## Children + +Use `$.of()` to lift primitives and Readables into children, and `collect()` to combine multiple children: + +```typescript +// Single child +yield* $.h1({}, $.of("Hello World")); + +// Multiple children +yield* $.div({}, collect( + $.of("Hello"), + $.span({}, $.of("World")), +)); + +// Empty child (renders nothing) +yield* $.div({}, $.empty); +``` + +`$.of()` accepts strings, numbers, Readables, and DOM nodes. When you pass a Readable, the text updates automatically when the value changes: + +```typescript +const count = yield* Signal.make(0); +yield* $.span({}, $.of(count)); // Updates in place when count changes +``` + +### Template Strings + +The `t` tagged template creates reactive strings from Readables: + +```typescript +import { t } from "@effex/dom"; + +const name = yield* Signal.make("World"); +const count = yield* Signal.make(0); + +// Creates a Readable that updates automatically +yield* $.p({}, $.of(t`Hello, ${name}! Count: ${count}`)); +``` + +## Attributes + +Elements accept an optional attributes object as the first argument. Attributes can be static or reactive: + +```typescript +$.input({ + // Standard attributes + type: "text", + placeholder: "Enter name", + disabled: true, + id: "name-input", + + // Reactive attributes — UI updates automatically + value: name, // Readable + class: className, // Readable + hidden: isHidden, // Readable + + // Style as object or string + style: { color: "red", fontSize: "16px" }, + + // Class as string, array, or Readable + class: ["btn", isActive.pipe(Readable.map(a => a ? "btn-active" : ""))], + + // Data and ARIA attributes + "data-testid": "name", + "aria-label": "Name input", + role: "textbox", + + // Ref binding + ref: inputRef, +}); +``` + +When you pass a Readable as an attribute value, Effex sets up a subscription — the DOM attribute updates in place whenever the Readable emits a new value. No re-rendering, no diffing. + +## Event Handlers + +Event handlers are functions that optionally return an Effect: + +```typescript +$.button({ + onClick: (e) => count.update((n) => n + 1), + onKeyDown: (e) => { + if (e.key === "Enter") return submit.run(); + }, + onSubmit: (e) => { + e.preventDefault(); + return handleSubmit(); + }, +}); +``` + +If the handler returns an Effect, Effex runs it. If it returns `void` or `undefined`, nothing extra happens. This means simple handlers stay simple, and complex ones get the full power of Effect. + +Supported events include `onClick`, `onInput`, `onChange`, `onSubmit`, `onKeyDown`, `onKeyUp`, `onFocus`, `onBlur`, `onMouseDown`, `onMouseUp`, `onMouseEnter`, `onMouseLeave`, `onPointerDown`, `onPointerUp`, `onPointerMove`, `onScroll`, `onWheel`, `onDragStart`, `onDrag`, `onDragEnd`, `onDrop`, `onDragOver`, `onTouchStart`, `onTouchMove`, `onTouchEnd`, `onAnimationEnd`, `onTransitionEnd`, and more. + +## SVG Elements + +SVG elements are also available on `$`: + +```typescript +$.svg({ viewBox: "0 0 24 24", width: 24, height: 24 }, + $.path({ d: "M12 2L2 22h20L12 2z", fill: "currentColor" }), +); +``` diff --git a/apps/docs/content/03-dom/01-components.md b/apps/docs/content/03-dom/01-components.md new file mode 100644 index 00000000..efa8c8bd --- /dev/null +++ b/apps/docs/content/03-dom/01-components.md @@ -0,0 +1,141 @@ +--- +title: "Components" +description: "Define components as plain functions or Effect generators, with context providers and children." +order: 1 +--- + +# Components + +Effex components are just functions that return Elements. There's no special component class, no hooks rules, and no lifecycle methods. A component is either a plain function (for static content) or an Effect generator (for state and context). + +## Simple Components + +Components without state or context requirements are plain functions: + +```typescript +import { $, collect } from "@effex/dom"; + +const Greeting = (props: { name: string }) => + $.h1({}, $.of(`Hello, ${props.name}!`)); +``` + +### With Children + +Use generics on `E` and `R` to propagate error and requirement types from children: + +```typescript +import { type Element } from "@effex/dom"; + +const Card = ( + props: { title: string }, + children: Element.Child, +) => + $.div({ class: "card" }, collect( + $.h2({}, $.of(props.title)), + children, + )); +``` + +This ensures that if the children require a context or may produce an error, those types flow through to the Card's return type. The compiler tracks them for you. + +## Stateful Components + +Use `Effect.gen` when you need signals, context, or other Effects: + +```typescript +import { Effect } from "effect"; +import { $, collect, Signal } from "@effex/dom"; + +const Counter = () => + Effect.gen(function* () { + const count = yield* Signal.make(0); + + return yield* $.div({}, collect( + $.button( + { onClick: () => count.update((n) => n - 1) }, + $.of("-"), + ), + $.span({}, $.of(count)), + $.button( + { onClick: () => count.update((n) => n + 1) }, + $.of("+"), + ), + )); + }); +``` + +The `yield*` is where Effects are executed. `Signal.make` creates a scoped signal, `$.div` creates a DOM element — both are Effects, so they compose naturally. + +### Accessing Context + +Components that depend on context simply `yield*` the context tag: + +```typescript +const UserBadge = () => + Effect.gen(function* () { + const user = yield* UserContext; + return yield* $.span({}, $.of(user.name)); + }); +``` + +The `UserContext` requirement appears in the component's `R` type channel. If you try to render `UserBadge` without providing `UserContext`, TypeScript catches it at compile time. + +## Context Providers + +Use `provide` to supply context to children: + +```typescript +import { Context, Effect } from "effect"; +import { $, provide } from "@effex/dom"; + +class ThemeContext extends Context.Tag("ThemeContext")< + ThemeContext, + Theme +>() {} + +const ThemedButton = (props: { label: string }) => + Effect.gen(function* () { + const theme = yield* ThemeContext; + return yield* $.button( + { style: { backgroundColor: theme.primary } }, + $.of(props.label), + ); + }); + +// Provide context to children +$.div({}, + provide(ThemeContext, myTheme, + ThemedButton({ label: "Click" }), + ), +); +``` + +`provide` removes `ThemeContext` from the `R` channel — downstream code no longer needs to satisfy that requirement. + +## Running Your App + +Use `runApp` and `mount` to start your application: + +```typescript +import { Effect } from "effect"; +import { mount, runApp } from "@effex/dom"; + +runApp( + Effect.gen(function* () { + yield* mount(App(), document.getElementById("root")!); + }), +); +``` + +`runApp` handles boilerplate: scoping, the signal registry, and keeping the process alive. You can pass additional layers: + +```typescript +import { Navigation } from "@effex/router"; + +runApp( + Effect.gen(function* () { + yield* mount(App(), document.getElementById("root")!); + }), + { layer: Navigation.makeLayer(router) }, +); +``` diff --git a/apps/docs/content/03-dom/02-boundaries.md b/apps/docs/content/03-dom/02-boundaries.md new file mode 100644 index 00000000..e4683737 --- /dev/null +++ b/apps/docs/content/03-dom/02-boundaries.md @@ -0,0 +1,67 @@ +--- +title: "Boundaries" +description: "Handle async loading states and errors with suspense and error boundaries." +order: 2 +--- + +# Boundaries + +Components that fetch data or do async work need loading and error states. Effex provides two boundary types: **suspense** for async rendering and **error** for catching failures. + +## Suspense + +Wrap async components in a suspense boundary to show a fallback while they load: + +```typescript +import { Boundary } from "@effex/dom"; + +Boundary.suspense({ + render: () => + Effect.gen(function* () { + const user = yield* fetchUser(id); + return yield* UserProfile({ user }); + }), + fallback: () => $.div({}, $.of("Loading...")), + catch: (error) => $.div({}, $.of(`Error: ${error.message}`)), + delay: "200 millis", // Avoid loading flash for fast responses +}); +``` + +The `delay` option prevents the fallback from showing if the async work completes quickly. If `fetchUser` resolves within 200ms, the user sees the content directly — no loading flicker. + +### How It Works + +1. Effex starts rendering the `render` function +2. If it encounters an async Effect that isn't resolved yet, it shows the `fallback` +3. When the async work completes, the fallback is replaced with the real content +4. If the async work fails, the `catch` handler renders instead + +## Error Boundary + +Catch errors from a component subtree without crashing the whole app: + +```typescript +Boundary.error( + () => RiskyComponent(), + (error) => $.div({}, $.of(`Failed: ${error.message}`)), +); +``` + +The error handler receives the error and returns an Element to render in place of the failed subtree. The rest of the application continues running. + +## Combining Boundaries + +Boundaries compose naturally. You can nest suspense inside error boundaries, or vice versa: + +```typescript +Boundary.error( + () => + Boundary.suspense({ + render: () => DataDashboard(), + fallback: () => DashboardSkeleton(), + }), + (error) => $.div({}, $.of("Dashboard unavailable")), +); +``` + +This catches both sync errors (thrown during render) and async failures (rejected Effects). diff --git a/apps/docs/content/03-dom/03-element-manipulation.md b/apps/docs/content/03-dom/03-element-manipulation.md new file mode 100644 index 00000000..f8b5e6a3 --- /dev/null +++ b/apps/docs/content/03-dom/03-element-manipulation.md @@ -0,0 +1,118 @@ +--- +title: "Element Manipulation" +description: "Imperative DOM operations via the Element namespace — attributes, classes, styles, focus, and more." +order: 3 +--- + +# Element Manipulation + +The `Element` namespace provides pipeable functions for imperative DOM operations. While most of the time you'll set attributes declaratively when creating elements, these functions are useful for refs, post-mount operations, and dynamic manipulation. + +## Getting a Reference + +Use `ref` to create a reference you can manipulate later: + +```typescript +import { ref, Element } from "@effex/dom"; + +const buttonRef = yield* ref(); + +// Pass to an element +yield* $.button({ ref: buttonRef }, $.of("Click me")); + +// Manipulate later — waits until the element is mounted +yield* buttonRef.pipe(Element.focus); + +// Check connection status +buttonRef.isConnected; // Readable +``` + +## Attributes + +```typescript +yield* el.pipe(Element.setAttribute("aria-expanded", "true")); +yield* el.pipe(Element.removeAttribute("disabled")); +yield* el.pipe(Element.toggleAttribute("hidden")); +yield* el.pipe(Element.hasAttribute("disabled")); // Effect + +// Reactive binding — attribute updates when Readable changes +yield* el.pipe(Element.bindAttribute("aria-label", labelReadable)); +yield* el.pipe(Element.bindBooleanAttribute("disabled", isDisabled)); +``` + +## Classes + +```typescript +yield* el.pipe(Element.addClass("active", "highlighted")); +yield* el.pipe(Element.removeClass("loading")); +yield* el.pipe(Element.toggleClass("expanded")); +yield* el.pipe(Element.replaceClass("old-class", "new-class")); +yield* el.pipe(Element.setClass("entirely-new-class")); + +// Reactive binding +yield* el.pipe(Element.bindClass(classNameReadable)); +``` + +## Styles + +```typescript +yield* el.pipe(Element.setStyle("backgroundColor", "red")); +yield* el.pipe(Element.setStyles({ opacity: "1", fontSize: "16px" })); +yield* el.pipe(Element.removeStyle("color")); + +// Reactive binding +yield* el.pipe(Element.bindStyle("color", colorReadable)); +``` + +## Data Attributes + +```typescript +yield* el.pipe(Element.setData("state", "open")); // data-state="open" +yield* el.pipe(Element.removeData("state")); +yield* el.pipe(Element.getData("state")); // Effect + +// Reactive binding +yield* el.pipe(Element.bindData("state", stateReadable)); +``` + +## Content + +```typescript +yield* el.pipe(Element.setTextContent("Hello")); +yield* el.pipe(Element.setInnerHTML("bold")); +yield* el.pipe(Element.setInputValue("new value")); // Without cursor reset + +// Reactive bindings +yield* el.pipe(Element.bindTextContent(textReadable)); +yield* el.pipe(Element.bindInputValue(valueReadable)); +``` + +## Focus & Interaction + +```typescript +yield* el.pipe(Element.focus); +yield* el.pipe(Element.blur); +yield* el.pipe(Element.click); +yield* el.pipe(Element.focusFirst("[data-item]")); // First matching descendant +yield* el.pipe(Element.focusLast("[data-item]")); + +yield* el.pipe(Element.getBoundingClientRect); // Effect +yield* el.pipe(Element.getId); // Effect +yield* el.pipe(Element.contains(childNode)); // Effect +``` + +## Event Listeners + +For adding listeners imperatively (usually you'd use `onClick` etc. on the element instead): + +```typescript +yield* el.pipe(Element.on("click", (e) => handleClick(e))); +yield* el.pipe(Element.once("transitionend", (e) => afterTransition())); +``` + +## Debugging + +```typescript +yield* el.pipe(Element.tap((node) => console.log(node))); +yield* el.pipe(Element.tapEffect((node) => Effect.log("mounted"))); +``` diff --git a/apps/docs/content/03-dom/04-animation.md b/apps/docs/content/03-dom/04-animation.md new file mode 100644 index 00000000..a74a235c --- /dev/null +++ b/apps/docs/content/03-dom/04-animation.md @@ -0,0 +1,145 @@ +--- +title: "Animation" +description: "CSS-based enter/exit animations for control flow transitions, with stagger functions for lists." +order: 4 +--- + +# Animation + +Effex uses CSS-based animations for enter/exit transitions. Animations are configured on control flow primitives — `when`, `match`, `each` — so they happen automatically when the reactive state changes. + +## Enter/Exit Animations + +Add an `animate` option to any control flow primitive: + +```typescript +import { when } from "@effex/dom"; + +when(isOpen, { + onTrue: () => Modal(), + onFalse: () => $.span(), + animate: { + enterFrom: "opacity-0 scale-95", + enterTo: "opacity-100 scale-100", + exit: "fade-out", + }, +}); +``` + +When `isOpen` becomes `true`, the entering element starts with the `enterFrom` classes, then transitions to `enterTo`. When it becomes `false`, the `exit` classes are applied and the element is removed after the animation completes. + +### Animation Options + +| Option | Description | +|--------|-------------| +| `enter` | Classes applied during the entire enter transition | +| `enterFrom` | Classes applied on the first frame, removed on the next | +| `enterTo` | Classes applied after `enterFrom` is removed | +| `exit` | Classes applied during the entire exit transition | +| `exitTo` | Classes applied after `exit`, element removed when transition ends | + +This follows the same model as Vue and Alpine.js transitions — you define CSS classes, and Effex manages the timing. + +## List Animations + +Animate items entering and leaving a list: + +```typescript +import { each, stagger } from "@effex/dom"; + +each(items, { + key: (item) => item.id, + render: (item) => ListItem(item), + animate: { + enter: "slide-in", + exit: "slide-out", + stagger: stagger(50), // 50ms between items + }, +}); +``` + +When items are added, each one's enter animation starts 50ms after the previous one. When items are removed, the exit animation plays before the DOM node is removed. + +## Stagger Functions + +Stagger functions control the timing between animated items in a list: + +```typescript +import { + stagger, + staggerFromCenter, + staggerEased, + delay, + sequence, + parallel, +} from "@effex/dom"; +``` + +| Function | Description | +|----------|-------------| +| `stagger(delayMs)` | Fixed delay between items — item 0 starts immediately, item 1 at 50ms, item 2 at 100ms, etc. | +| `staggerFromCenter(delayMs)` | Items animate outward from the center of the list | +| `staggerEased(totalDurationMs, easingFn)` | Distribute items across a total duration using an easing curve | +| `delay(delayMs)` | Same fixed delay for all items | +| `sequence(...delays)` | Explicit delay for each item position | +| `parallel()` | All items animate simultaneously (no stagger) | + +### Example: Center-Out Stagger + +```typescript +each(menuItems, { + key: (item) => item.id, + render: (item) => MenuItem(item), + animate: { + enter: "scale-in", + stagger: staggerFromCenter(30), + }, +}); +``` + +Items in the middle of the list animate first, then the animation ripples outward to both ends. + +## CSS Setup + +Effex applies classes but doesn't include any CSS. Define your transitions in your stylesheet: + +```css +/* Fade in */ +.fade-in { + transition: opacity 150ms ease-in; +} + +/* Slide in from below */ +.slide-in { + animation: slideIn 200ms ease-out; +} + +@keyframes slideIn { + from { + opacity: 0; + transform: translateY(8px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +/* Fade out */ +.fade-out { + transition: opacity 150ms ease-out; + opacity: 0; +} +``` + +With Tailwind CSS, you can use utility classes directly: + +```typescript +animate: { + enterFrom: "opacity-0 translate-y-2", + enter: "transition-all duration-150", + enterTo: "opacity-100 translate-y-0", + exit: "transition-all duration-150", + exitTo: "opacity-0", +} +``` diff --git a/apps/docs/content/03-dom/05-virtual-lists.md b/apps/docs/content/03-dom/05-virtual-lists.md new file mode 100644 index 00000000..7dae399d --- /dev/null +++ b/apps/docs/content/03-dom/05-virtual-lists.md @@ -0,0 +1,74 @@ +--- +title: "Virtual Lists" +description: "Render large lists efficiently by only mounting visible items with virtualEach." +order: 5 +--- + +# Virtual Lists + +For lists with thousands of items, rendering everything at once is too slow. `virtualEach` only renders the items currently visible in the viewport, plus a small buffer above and below. + +## Basic Usage + +```typescript +import { virtualEach } from "@effex/dom"; + +virtualEach(items, { + key: (item) => item.id, + itemHeight: 48, + height: 400, + render: (item) => + $.li({}, $.of(item.pipe(Readable.map((i) => i.text)))), +}); +``` + +The `itemHeight` and `height` are in pixels. `itemHeight` is the height of each row, and `height` is the height of the scrollable container. Items outside the visible range are not mounted at all — they're created on scroll and cleaned up when they leave the viewport. + +## Scroll Control + +Use `VirtualListRef` to programmatically control scrolling: + +```typescript +import { virtualEach, VirtualListRef } from "@effex/dom"; + +const listRef = yield* VirtualListRef.make(); + +yield* virtualEach(items, { + key: (item) => item.id, + itemHeight: 60, + height: 400, + overscan: 5, // Render 5 extra items above/below viewport + ref: listRef, + render: (item, index) => ListItem({ item, index }), +}); + +// Scroll to item 100 +yield* listRef.ready.pipe( + Effect.flatMap((control) => control.scrollTo(100)), +); +``` + +### Control API + +Once the list is ready, the control object provides: + +```typescript +control.scrollTo(index); // Scroll to a specific item +control.scrollToTop(); // Scroll to the top +control.scrollToBottom(); // Scroll to the bottom +control.visibleRange; // Readable<{ start: number, end: number }> +control.totalItems; // Readable +``` + +## Options + +| Option | Type | Description | +|--------|------|-------------| +| `key` | `(item) => string \| number` | Unique key for each item | +| `itemHeight` | `number` | Height of each row in pixels | +| `height` | `number` | Height of the scrollable container | +| `overscan` | `number` | Extra items to render outside the viewport (default: 3) | +| `ref` | `VirtualListRef` | Ref for scroll control | +| `render` | `(item, index) => Element` | Render function for each item | + +The `overscan` option controls how many extra items are rendered above and below the visible area. Higher values reduce the chance of seeing blank space during fast scrolling, at the cost of rendering more items. diff --git a/apps/docs/content/03-dom/06-utilities.md b/apps/docs/content/03-dom/06-utilities.md new file mode 100644 index 00000000..e131931d --- /dev/null +++ b/apps/docs/content/03-dom/06-utilities.md @@ -0,0 +1,97 @@ +--- +title: "Utilities" +description: "Portal, FocusTrap, ScrollLock, UniqueId, and other DOM utilities." +order: 6 +--- + +# Utilities + +Effex includes several DOM utilities for common UI patterns: rendering into portals, trapping focus in modals, locking scroll, and generating unique IDs. + +## Portal + +Render children into a different DOM node, outside the current component tree. Useful for modals, tooltips, and dropdowns that need to escape `overflow: hidden` or `z-index` stacking contexts: + +```typescript +import { Portal } from "@effex/dom"; + +// Render into document.body (default) +Portal(() => Modal({ title: "Hello" })); + +// Render into a specific element by selector +Portal({ target: "#modal-root" }, () => Dropdown()); + +// Render into a specific element by reference +Portal({ target: existingElement }, () => Tooltip()); +``` + +The portaled content participates in the same Effect scope as the parent — context, signals, and cleanup all work as expected. + +## FocusTrap + +Trap keyboard focus within a container. When the user presses Tab at the last focusable element, focus wraps to the first one. Essential for accessible modals and dialogs: + +```typescript +import { FocusTrap } from "@effex/dom"; + +yield* FocusTrap.make({ + container: dialogElement, + initialFocus: firstInput, // Optional: focus this element on activation + returnFocus: triggerElement, // Optional: return focus here when released +}); +// Focus is trapped until the scope closes +``` + +When the scope finalizes (e.g., the modal unmounts), the focus trap is released and focus returns to `returnFocus` if specified. + +## ScrollLock + +Prevent body scrolling while a modal or overlay is open. Handles scrollbar width compensation to prevent layout shift: + +```typescript +import { ScrollLock } from "@effex/dom"; + +yield* ScrollLock.lock; +// Body scroll is locked until the scope closes +``` + +When locked, the body gets `overflow: hidden` and a padding-right equal to the scrollbar width, so the page doesn't shift when the scrollbar disappears. + +## UniqueId + +Generate unique IDs for linking related elements — labels to inputs, ARIA attributes, etc.: + +```typescript +import { UniqueId } from "@effex/dom"; + +const labelId = yield* UniqueId.make("label"); +const inputId = yield* UniqueId.make("input"); + +yield* $.div({}, collect( + $.label({ id: labelId, htmlFor: inputId }, $.of("Name")), + $.input({ id: inputId, "aria-labelledby": labelId }), +)); +``` + +IDs are unique within the application and include the optional prefix for readability in the DOM inspector. + +## Ref + +Create a reference to a DOM element for later imperative access: + +```typescript +import { ref } from "@effex/dom"; + +const inputRef = yield* ref(); + +// Pass to an element +yield* $.input({ ref: inputRef, type: "text" }); + +// Use later — waits until the element is mounted +yield* inputRef.pipe(Element.focus); + +// Check connection status reactively +inputRef.isConnected; // Readable +``` + +`ref` is built on `Ref` from `@effex/core` — it's a deferred value that resolves when the element mounts. Accessing `inputRef.pipe(...)` before the element exists will wait until it's available. diff --git a/apps/docs/content/04-reactivity/00-signals-and-readables.md b/apps/docs/content/04-reactivity/00-signals-and-readables.md new file mode 100644 index 00000000..bcfacfb1 --- /dev/null +++ b/apps/docs/content/04-reactivity/00-signals-and-readables.md @@ -0,0 +1,192 @@ +--- +title: "Signals & Readables" +description: "The foundation of Effex's reactivity system — mutable Signals and composable Readables." +order: 0 +--- + +# Signals & Readables + +Effex's reactivity is built on two primitives: **Signals** (mutable reactive values) and **Readables** (observable values that can be derived, combined, and composed). Every reactive behavior in Effex traces back to these two types. + +## Signals + +A Signal holds a value that can be read and written. When the value changes, anything observing it updates automatically. + +```typescript +import { Effect } from "effect"; +import { Signal } from "@effex/dom"; + +const count = yield* Signal.make(0); + +// Read the current value +const current = yield* count.get; + +// Set a new value +yield* count.set(5); + +// Update based on the current value +yield* count.update((n) => n + 1); +``` + +Signals are scoped — they're created within an Effect and cleaned up when the scope finalizes. This means no manual teardown or memory leak worries. + +### Custom Equality + +By default, Signals use strict equality (`===`) to decide if a value has changed. If the new value is the same as the old one, observers aren't notified. You can customize this with `Signal.equals`: + +```typescript +interface User { + id: number; + name: string; + lastSeen: Date; +} + +// Only trigger updates when the user ID changes +const currentUser = yield* Signal.make( + { id: 1, name: "Alice", lastSeen: new Date() } +).pipe(Signal.equals((a, b) => a.id === b.id)); +``` + +This is useful when your value contains fields that change frequently but aren't semantically meaningful — like timestamps or metadata. + +### From Nullable or Reactive Values + +```typescript +// Use an existing signal if provided, or create a new one +const value = yield* Signal.fromNullable(existingSignal, "default"); + +// Convert any Reactive (static value or Readable) into a Signal +const editable = yield* Signal.fromReactive(props.label, "fallback"); +``` + +## Readables + +Readables are the read-only side of reactivity. Every Signal is a Readable, but not every Readable is a Signal. You can derive new Readables from existing ones without creating new mutable state. + +### Derived Values + +`Readable.map` transforms a Readable's value: + +```typescript +import { Readable, Signal } from "@effex/dom"; + +const firstName = yield* Signal.make("John"); +const lastName = yield* Signal.make("Doe"); + +// Derived from a single source +const upperFirst = Readable.map(firstName, (s) => s.toUpperCase()); + +// Combine two readables +const fullName = Readable.zipWith( + firstName, + lastName, + (first, last) => `${first} ${last}`, +); + +// Combine into a tuple +const both = Readable.zipAll([firstName, lastName]); +// both: Readable<[string, string]> +``` + +Derived Readables update automatically when their sources change. There's no manual subscription management. + +### Constants and Streams + +```typescript +// A Readable that never changes +const label = Readable.of("Hello"); + +// From an initial value and a stream of updates +const time = Readable.fromStream(Date.now(), clockStream); +``` + +### Normalizing Props + +Components often accept props that can be either static values or Readables. The `Reactive` type represents this, and `Readable.normalize` handles both cases: + +```typescript +// Reactive means: T | Readable +interface ButtonProps { + disabled?: Readable.Reactive; + class?: Readable.Reactive; +} + +const Button = (props: ButtonProps, child: Child) => { + const disabled = Readable.normalize(props.disabled ?? false); + const className = Readable.normalize(props.class ?? ""); + + const ariaDisabled = disabled.pipe( + Readable.map((d) => (d ? "true" : undefined)), + ); + + return $.button( + { class: className, disabled, "aria-disabled": ariaDisabled }, + child, + ); +}; +``` + +Whether the caller passes `disabled={true}` or `disabled={someSignal}`, your component handles it the same way. + +### Lifting Functions + +If you use utility libraries like [class-variance-authority](https://cva.style/docs) or [clsx](https://github.com/lukeed/clsx), `Readable.lift` makes them reactive-aware: + +```typescript +import { cva } from "class-variance-authority"; +import { Signal, Readable } from "@effex/dom"; + +const buttonStyles = cva("btn font-medium rounded", { + variants: { + variant: { primary: "bg-blue-500", secondary: "bg-gray-200" }, + size: { sm: "px-2 py-1", md: "px-4 py-2", lg: "px-6 py-3" }, + }, +}); + +// Lift it to accept Readables in the options +const reactiveButtonStyles = Readable.lift(buttonStyles); + +const variant = yield* Signal.make<"primary" | "secondary">("primary"); + +// className is Readable — updates when variant changes +const className = reactiveButtonStyles({ variant, size: "md" }); +``` + +### Filtering and Deduplication + +```typescript +// Only emit values that pass the predicate +const positiveOnly = Readable.filter(count, (n) => n > 0); + +// Skip consecutive duplicates +const deduped = Readable.dedupe(value); + +// Dedupe with custom equality +const dedupedById = Readable.dedupeWith(user, (a, b) => a.id === b.id); +``` + +### Side Effects + +`Readable.tap` runs a side effect whenever the value changes, without transforming it: + +```typescript +const logged = Readable.tap(count, (n) => console.log("count is now", n)); +``` + +## Ref + +For cases where you need a mutable reference that's set later — like a DOM element ref — use `Ref`: + +```typescript +import { Ref } from "@effex/dom"; + +const inputRef = yield* Ref.make(); + +// inputRef.current is null until set +// inputRef.value is an Effect that resolves when the ref is populated + +// Focus the input (waits until the ref is set) +yield* inputRef.value.pipe( + Effect.tap((el) => Effect.sync(() => el.focus())), +); +``` diff --git a/apps/docs/content/04-reactivity/01-async-state.md b/apps/docs/content/04-reactivity/01-async-state.md new file mode 100644 index 00000000..67186875 --- /dev/null +++ b/apps/docs/content/04-reactivity/01-async-state.md @@ -0,0 +1,246 @@ +--- +title: "Async State" +description: "Manage loading, error, and success states for async operations with AsyncReadable, Mutation, and AsyncCache." +order: 1 +--- + +# Async State + +Real applications need to fetch data, submit forms, and handle loading and error states. Effex provides three primitives for this: **AsyncReadable** for data that loads automatically, **Mutation** for operations you trigger manually, and **AsyncCache** for coordinating data across your app. + +## AsyncReadable + +An AsyncReadable wraps an async operation and exposes reactive state for loading, value, and error: + +```typescript +import { AsyncReadable } from "@effex/dom"; + +const userData = yield* AsyncReadable.make(() => + Effect.gen(function* () { + const response = yield* fetchUser(userId); + return response.data; + }) +); + +// Reactive state properties +userData.isLoading; // Readable +userData.value; // Readable> +userData.error; // Readable> + +// Manually trigger refetch +yield* userData.refetch(); + +// Reset to initial state +yield* userData.reset(); +``` + +The operation runs immediately when the AsyncReadable is created. The `isLoading`, `value`, and `error` Readables update as the operation progresses. + +### From Promises + +If you're working with promise-based APIs: + +```typescript +// Simple promise +const data = yield* AsyncReadable.promise( + () => fetch("/api/data").then((r) => r.json()) +); + +// With typed error handling +const data = yield* AsyncReadable.tryPromise( + () => fetch("/api/data").then((r) => r.json()), + (error) => new ApiError({ cause: error }), +); +``` + +### Reactive Dependencies + +The most powerful pattern: recompute when a source Readable changes. + +```typescript +const userId = yield* Signal.make("alice"); + +const profile = yield* AsyncReadable.fromReadable( + (id) => Effect.tryPromise(() => fetchProfile(id)) +)(userId); +// Refetches automatically when userId changes +``` + +When `userId` changes from `"alice"` to `"bob"`, the profile refetches automatically. The `isLoading` state reflects the new fetch, and the previous value remains available until the new one arrives. + +### Rendering Async State + +Async state works naturally with Effex's control flow primitives: + +```typescript +import { when, matchOption } from "@effex/dom"; + +// Show loading spinner +when(userData.isLoading, { + onTrue: () => Spinner(), + onFalse: () => $.span(), +}); + +// Handle the value +matchOption(userData.value, { + onSome: (user) => UserCard({ user }), // user is Readable + onNone: () => $.span({}, $.of("No data")), +}); +``` + +## Mutation + +Unlike AsyncReadable, a Mutation doesn't run until you tell it to. Use it for form submissions, API calls, or any operation triggered by user action: + +```typescript +import { Mutation } from "@effex/dom"; + +const createUser = yield* Mutation.make((input: CreateUserInput) => + Effect.gen(function* () { + const response = yield* api.createUser(input); + return response.user; + }) +); + +// Same reactive state as AsyncReadable +createUser.isLoading; // Readable +createUser.data; // Readable> +createUser.error; // Readable> + +// Execute the mutation +const user = yield* createUser.run({ + name: "Alice", + email: "alice@example.com", +}); + +// Reset state +yield* createUser.reset(); +``` + +### From Promises + +```typescript +const createUser = yield* Mutation.promise( + (input: CreateUserInput) => + fetch("/api/users", { + method: "POST", + body: JSON.stringify(input), + }).then((r) => r.json()) +); + +// With error handling +const createUser = yield* Mutation.tryPromise( + (input: CreateUserInput) => + fetch("/api/users", { + method: "POST", + body: JSON.stringify(input), + }).then((r) => r.json()), + (error) => new ApiError({ cause: error }), +); +``` + +### Transforming Results + +```typescript +// Transform the output +const createUserName = Mutation.map(createUser, (user) => user.name); + +// Chain mutations +const createAndVerify = Mutation.flatMap(createUser, (user) => + Mutation.make(() => api.sendVerificationEmail(user.email)) +); +``` + +## AsyncCache + +AsyncCache coordinates async data across your application. It deduplicates requests, caches results, and supports hierarchical invalidation. + +```typescript +import { AsyncCache } from "@effex/dom"; + +const cache = yield* AsyncCache; + +// Get or create a cached async readable +const posts = yield* cache.get( + ["posts"], + () => Effect.tryPromise(() => + fetch("/api/posts").then((r) => r.json()) + ), +); +``` + +If two components request the same cache key, they share the same AsyncReadable — no duplicate fetches. + +### Seeding with Loader Data + +In SSR or SSG apps, you often have data from a server-side loader. Seed the cache so the client doesn't refetch on hydration: + +```typescript +const posts = yield* cache.get( + ["posts"], + () => Effect.tryPromise(() => + fetch("/api/posts").then((r) => r.json()) + ), + { initialData: loaderData.posts }, +); +``` + +### Hierarchical Keys + +Cache keys are arrays, which enables prefix-based invalidation: + +```typescript +// Key examples +["posts"] // all posts +["posts", "feed"] // feed posts specifically +["posts", userId] // posts by user +["users", 42, true] // compound keys +``` + +### Invalidation + +After a mutation, invalidate related cache entries to trigger a refetch: + +```typescript +// Invalidate all entries starting with ["posts"] — triggers refetch +yield* cache.invalidate(["posts"]); + +// Invalidate a specific user's data +yield* cache.invalidate(["users", userId]); + +// Remove entries entirely (no refetch) +yield* cache.remove(["posts"]); + +// Clear the whole cache +yield* cache.clear(); +``` + +### Typical Pattern: Loader + Cache + Mutation + +```typescript +const FeedPage = (data: { posts: Post[] }) => + Effect.gen(function* () { + const cache = yield* AsyncCache; + + // Seed cache with server-loaded data + const feedQuery = yield* cache.get( + ["feed"], + () => Effect.tryPromise(() => + fetch("/?_data=1").then((r) => r.json()).then((r) => r.data) + ), + { initialData: data }, + ); + + const posts = Readable.map(feedQuery.value, (fetched) => + Option.match(fetched, { + onSome: (f) => f.posts, + onNone: () => data.posts, + }), + ); + + // After a mutation, invalidate to refetch + yield* cache.invalidate(["feed"]); + }); +``` + +This pattern gives you server-loaded data on first render, client-side refetching after mutations, and deduplication if multiple components read the same data. diff --git a/apps/docs/content/04-reactivity/02-reactive-collections.md b/apps/docs/content/04-reactivity/02-reactive-collections.md new file mode 100644 index 00000000..350fdfab --- /dev/null +++ b/apps/docs/content/04-reactivity/02-reactive-collections.md @@ -0,0 +1,178 @@ +--- +title: "Reactive Collections" +description: "In-place mutable arrays, maps, sets, and structs that automatically trigger reactive updates." +order: 2 +--- + +# Reactive Collections + +In React, updating a Map or Set requires cloning the entire collection on every mutation because the framework detects changes by reference. Effex takes a different approach: reactive collections allow in-place mutations that automatically notify observers. + +```tsx +// React (clone on every mutation) +setMap(new Map(map).set(key, value)); +setSet(new Set(set).add(item)); + +// Effex (mutate in place, O(1)) +yield* users.set(key, value); +yield* tags.add(item); +``` + +## Signal.Array + +A reactive array with familiar mutation methods: + +```typescript +const todos = yield* Signal.Array.make([]); + +// In-place mutations — no cloning +yield* todos.push({ id: 1, text: "Learn Effex", done: false }); +yield* todos.unshift(firstItem); +yield* todos.pop(); +yield* todos.shift(); +yield* todos.splice(1, 2, replacement); +yield* todos.insertAt(0, item); +yield* todos.removeAt(index); +yield* todos.remove(specificItem); // By reference +yield* todos.clear(); + +// Reordering +yield* todos.move(fromIndex, toIndex); // Great for drag-and-drop +yield* todos.swap(indexA, indexB); +yield* todos.sort((a, b) => a.id - b.id); +yield* todos.reverse(); + +// Bulk operations +yield* todos.update((arr) => arr.filter((t) => !t.done)); +yield* todos.set(newTodos); + +// Reactive length +todos.length; // Readable +``` + +### Rendering Lists + +Signal.Array works directly with `each` for keyed list rendering: + +```typescript +each(todos, { + key: (todo) => todo.id, + render: (todo) => TodoItem(todo), +}); +``` + +When you `push`, `removeAt`, or `move` items, only the affected DOM nodes are created, removed, or repositioned. The rest of the list is untouched. + +## Signal.Map + +A reactive key-value store: + +```typescript +const users = yield* Signal.Map.make(); + +// Mutations +yield* users.set("u1", { name: "Alice", role: "admin" }); +yield* users.delete("u1"); +yield* users.clear(); +yield* users.replace(newMap); +yield* users.update((m) => new Map([...m, ["u2", bob]])); +``` + +### Reading Values + +Signal.Map provides two kinds of reads: **reactive** (for binding to UI) and **one-time** (for imperative code). + +```typescript +// Reactive reads — update the UI when the value changes +users.at("u1"); // Readable> +users.atOrElse("u1", guest); // Readable +users.has("u1"); // Readable + +// One-time reads — for use in Effects +const user = yield* users.atEffect("u1"); // Effect> +const exists = yield* users.hasEffect("u1"); // Effect + +// Reactive derived values +users.size; // Readable +users.entries; // Readable +users.keys; // Readable +users.valuesArray; // Readable +``` + +## Signal.Set + +A reactive collection of unique values: + +```typescript +const tags = yield* Signal.Set.make(["draft"]); + +// Mutations +yield* tags.add("important"); +yield* tags.delete("draft"); +yield* tags.toggle("featured"); // Add if missing, remove if present +yield* tags.clear(); +yield* tags.replace(newSet); +yield* tags.update((s) => new Set([...s, "extra"])); + +// Reactive reads +tags.has("important"); // Readable + +// One-time read +const exists = yield* tags.hasEffect("important"); // Effect + +// Reactive derived values +tags.size; // Readable +tags.valuesArray; // Readable +``` + +The `toggle` method is particularly useful for UI state like selected items, active filters, or feature flags. + +## Signal.Struct + +A reactive object where each field is its own Signal. This gives you granular reactivity — updating one field doesn't notify observers of other fields. + +```typescript +const address = yield* Signal.Struct.make({ + street: "123 Main St", + city: "Austin", + zip: "78701", +}); + +// Each field is a Signal — granular reads and writes +yield* address.street.set("456 Oak Ave"); +yield* address.city.update((c) => c.toUpperCase()); + +// Read the whole struct as a single Readable +const value = yield* address.get; +// { street: "456 Oak Ave", city: "AUSTIN", zip: "78701" } + +// Batch update multiple fields +yield* address.update({ street: "789 Pine Rd", city: "Houston" }); + +// Replace the entire struct +yield* address.replace({ + street: "100 New St", + city: "San Antonio", + zip: "78201", +}); + +// List of field keys +address.keys; // readonly ["street", "city", "zip"] +``` + +### When to Use Struct vs. Individual Signals + +Use `Signal.Struct` when you have a group of related fields that are often read or updated together (like a form). Use individual Signals when the values are independent. + +```typescript +// Related fields — use Struct +const formData = yield* Signal.Struct.make({ + name: "", + email: "", + role: "user", +}); + +// Independent values — use individual Signals +const isOpen = yield* Signal.make(false); +const count = yield* Signal.make(0); +``` diff --git a/apps/docs/content/04-reactivity/03-state-machines.md b/apps/docs/content/04-reactivity/03-state-machines.md new file mode 100644 index 00000000..89fbcdc4 --- /dev/null +++ b/apps/docs/content/04-reactivity/03-state-machines.md @@ -0,0 +1,167 @@ +--- +title: "State Machines" +description: "Declarative state machines with type-safe transitions and reactive guards using Transition." +order: 3 +--- + +# State Machines + +Complex UI often has states that should only transition in specific ways — a form that can't submit while already loading, a modal that must close before another opens, a wizard that can't skip steps. `Transition` makes these constraints explicit and enforced at both the type level and runtime. + +## Defining a State Machine + +A Transition takes a map of states to their allowed targets and an initial state: + +```typescript +import { Transition } from "@effex/dom"; + +const status = yield* Transition.make( + { + idle: ["loading"], + loading: ["success", "error"], + success: ["idle"], + error: ["idle", "loading"], + }, + "idle" +); +``` + +This defines a state machine where: +- `idle` can only go to `loading` +- `loading` can go to `success` or `error` +- `success` can go back to `idle` +- `error` can retry (`loading`) or reset (`idle`) + +### Reading State + +```typescript +// Current state as a reactive Readable +status.current; // Readable<"idle" | "loading" | "success" | "error"> + +// Check if in a specific state (reactive) +status.is("idle"); // Readable +status.is("loading"); // Readable + +// Check if a transition is allowed (reactive) +status.canTransitionTo("success"); // Readable +``` + +All of these are Readables, so they work directly with Effex's control flow: + +```typescript +match(status.current, { + cases: [ + { pattern: "idle", render: () => IdleView() }, + { pattern: "loading", render: () => LoadingSpinner() }, + { pattern: "error", render: () => ErrorView() }, + ], +}); +``` + +### Transitioning + +```typescript +// Transition to a new state +yield* status.to("loading"); // Effect + +// If the transition isn't allowed, it fails with InvalidTransition +// e.g., from "idle" you can't go to "success" directly +``` + +The transition fails with an `InvalidTransition` error if the current state doesn't allow it. This is caught at the type level too — TypeScript won't let you call `status.to()` with a state that's never reachable from anywhere. + +## Guarded Transitions + +Sometimes a transition should only be allowed when a condition is met. Guards are reactive — the `canTransitionTo` Readable updates automatically when the guard's value changes. + +```typescript +const isOnline = yield* Signal.make(true); + +const status = yield* Transition.make( + { + idle: [ + { to: "loading", when: isOnline }, // guarded + "error", // unguarded + ], + loading: ["success", "error"], + success: ["idle"], + error: ["idle"], + }, + "idle" +); + +// canTransitionTo respects guards — updates reactively +status.canTransitionTo("loading"); // true only when isOnline is true + +// Transition fails if guard is false +yield* status.to("loading"); // InvalidTransition if offline +``` + +You can use this to disable buttons, hide options, or prevent actions based on dynamic conditions. + +## Guarded Callbacks + +`transition.guard` creates a callback that only runs when the machine is in specific states: + +```typescript +const submit = status.guard( + ["idle"], // only enabled in these states + (data: FormData) => + Effect.gen(function* () { + yield* status.to("loading"); + return yield* api.submit(data); + }), + { onBlocked: "ignore" } // or "fail" (default) +); + +yield* submit(formData); +``` + +With `onBlocked: "ignore"`, calling `submit` while loading is a no-op. With `onBlocked: "fail"`, it produces an error. Either way, you don't need to manually check state before calling the function. + +## Practical Example: Form Submission + +```typescript +const formStatus = yield* Transition.make( + { + idle: ["submitting"], + submitting: ["success", "error"], + success: ["idle"], + error: ["idle", "submitting"], + }, + "idle" +); + +const submitForm = formStatus.guard( + ["idle", "error"], // can submit from idle or retry from error + (data: FormData) => + Effect.gen(function* () { + yield* formStatus.to("submitting"); + const result = yield* api.submitForm(data); + yield* formStatus.to("success"); + return result; + }).pipe( + Effect.catchAll((error) => + Effect.gen(function* () { + yield* formStatus.to("error"); + return yield* Effect.fail(error); + }) + ) + ), +); + +// In the UI +const submitButton = $.button( + { + disabled: Readable.map( + formStatus.canTransitionTo("submitting"), + (can) => !can, + ), + onClick: () => submitForm(formData), + }, + when(formStatus.is("submitting"), { + onTrue: () => $.of("Submitting..."), + onFalse: () => $.of("Submit"), + }), +); +``` diff --git a/apps/docs/content/04-reactivity/04-control-flow.md b/apps/docs/content/04-reactivity/04-control-flow.md new file mode 100644 index 00000000..c256d84b --- /dev/null +++ b/apps/docs/content/04-reactivity/04-control-flow.md @@ -0,0 +1,159 @@ +--- +title: "Control Flow" +description: "Reactive conditionals, pattern matching, and list rendering with when, match, each, and more." +order: 4 +--- + +# Control Flow + +Effex doesn't use JSX or templates. Instead, it provides reactive control flow primitives — functions that take Readables and produce elements that update automatically when the source values change. + +## when + +Conditional rendering based on a boolean Readable: + +```typescript +import { when } from "@effex/dom"; + +when(isLoggedIn, { + onTrue: () => Dashboard(), + onFalse: () => LoginForm(), +}); +``` + +When `isLoggedIn` changes, the previous branch is unmounted and the new one is rendered. This is a full swap — each branch gets its own lifecycle. + +## match + +Pattern matching on a Readable value. Like a reactive `switch`: + +```typescript +import { match } from "@effex/dom"; + +match(status.current, { + cases: [ + { pattern: "idle", render: () => IdleView() }, + { pattern: "loading", render: () => LoadingSpinner() }, + { pattern: "success", render: () => SuccessView() }, + { pattern: "error", render: () => ErrorView() }, + ], + fallback: () => $.div({}, $.of("Unknown state")), +}); +``` + +Only one case renders at a time. When the value changes, the active case is swapped. The `fallback` is optional — if omitted and no case matches, nothing renders. + +## matchOption + +Unwrap an `Option` reactively: + +```typescript +import { matchOption } from "@effex/dom"; + +matchOption(userData.value, { + onSome: (user) => UserCard({ user }), // user is Readable + onNone: () => $.span({}, $.of("No data")), +}); +``` + +The key detail: inside `onSome`, the value is a `Readable`, not a `User`. This means when the Option's inner value changes (say, from one user to another), the `UserCard` component updates in place rather than being unmounted and remounted. The branch only swaps when the Option goes from `Some` to `None` or vice versa. + +## matchEither + +Same idea, for `Either`: + +```typescript +import { matchEither } from "@effex/dom"; + +matchEither(result, { + onRight: (value) => SuccessView({ value }), // value is Readable
    + onLeft: (error) => ErrorView({ error }), // error is Readable +}); +``` + +## each + +Keyed list rendering with efficient reconciliation: + +```typescript +import { each } from "@effex/dom"; + +each(todos, { + key: (todo) => todo.id, + render: (todo, index) => TodoItem({ todo, index }), + container: () => $.ul({ class: "todo-list" }), +}); +``` + +The `key` function is required. It tells Effex how to track items across updates. When the list changes: + +- **New items** are created and inserted at the correct position +- **Removed items** are unmounted and their DOM nodes removed +- **Moved items** are repositioned without re-rendering +- **Unchanged items** are left completely untouched + +The `todo` parameter inside `render` is a `Readable` — if an item's data changes without the key changing, the existing component updates in place. + +The `container` is optional. If provided, it wraps the list items. If omitted, items are rendered as siblings. + +### With Signal.Array + +`each` works directly with reactive arrays: + +```typescript +const items = yield* Signal.Array.make([]); + +// Push, remove, move — each updates the DOM minimally +yield* items.push(newItem); +yield* items.removeAt(2); +yield* items.move(0, 3); + +each(items, { + key: (item) => item.id, + render: (item) => ItemCard({ item }), +}); +``` + +## redraw + +For cases where the entire subtree depends on the value and should be rebuilt from scratch on every change: + +```typescript +import { redraw } from "@effex/dom"; + +redraw(theme, { + render: (currentTheme) => ThemedApp({ theme: currentTheme }), +}); +``` + +Unlike `match` or `when`, `redraw` unmounts and remounts on every change, not just branch switches. Use this sparingly — it's a full teardown and rebuild. + +## Nesting Control Flow + +These primitives compose naturally: + +```typescript +when(isLoggedIn, { + onTrue: () => + match(currentPage, { + cases: [ + { + pattern: "dashboard", + render: () => + matchOption(userData.value, { + onSome: (user) => + each(user.pipe(Readable.map((u) => u.notifications)), { + key: (n) => n.id, + render: (notification) => NotificationCard({ notification }), + }), + onNone: () => LoadingSpinner(), + }), + }, + { pattern: "settings", render: () => SettingsPage() }, + ], + }), + onFalse: () => LoginPage(), +}); +``` + +Each level reacts independently to its own source Readable. Changing `currentPage` doesn't affect the `isLoggedIn` branch, and vice versa. diff --git a/apps/docs/content/05-router/00-defining-routes.md b/apps/docs/content/05-router/00-defining-routes.md new file mode 100644 index 00000000..38110b4f --- /dev/null +++ b/apps/docs/content/05-router/00-defining-routes.md @@ -0,0 +1,176 @@ +--- +title: "Defining Routes" +description: "Create routes with typed params, search params, loaders, guards, animations, and error handling." +order: 0 +--- + +# Defining Routes + +Routes are the building blocks of an Effex application's URL structure. A route connects a URL pattern to a component, optionally with typed parameters, data loading, and error handling. + +## Basic Routes + +Create a route with `Route.make` and add a render function with `Route.render`: + +```typescript +import { Route } from "@effex/router"; +import { $, collect } from "@effex/dom"; + +const HomeRoute = Route.make("/").pipe( + Route.render(() => $.h1({}, $.of("Welcome home"))), +); +``` + +Routes are built with a pipe-based combinator API. `Route.make` creates a bare route (with no render function), and you compose behavior onto it. + +## Typed Params + +URL parameters like `/users/:id` are strings by default. Use `Route.params` with an Effect Schema to validate and transform them: + +```typescript +import { Schema } from "effect"; +import { Route } from "@effex/router"; + +const UserRoute = Route.make("/users/:id").pipe( + Route.params(Schema.Struct({ id: Schema.NumberFromString })), + Route.render(() => UserPage()), +); +``` + +Now `id` is a `number`, not a string. If the URL contains a non-numeric ID, the schema validation fails with a `ParseError`. + +### Accessing Params in Components + +Each route creates a unique context tag for its params. Access them with `yield*`: + +```typescript +const UserPage = () => + Effect.gen(function* () { + const { id } = yield* UserRoute.params; // number + return yield* $.div({}, $.of(`User ${id}`)); + }); +``` + +### Search Params + +Query string parameters work the same way: + +```typescript +const SearchRoute = Route.make("/search").pipe( + Route.searchParams( + Schema.Struct({ + q: Schema.String, + page: Schema.optional(Schema.NumberFromString).pipe( + Schema.withDefault(() => 1), + ), + }), + ), + Route.render(() => SearchPage()), +); + +// In SearchPage: +const { q, page } = yield* SearchRoute.searchParams; +// q: string, page: number (defaults to 1) +``` + +### Raw Params + +If you don't need schema validation, use `Route.rawParams` to keep the raw string dictionary: + +```typescript +const ProfileRoute = Route.make("/profile/:username").pipe( + Route.rawParams, + Route.render(() => ProfilePage()), +); + +// In ProfilePage: +const { username } = yield* ProfileRoute.params; // string +``` + +## Data Loading + +`Route.get` adds a server-side loader and a render function that receives the loaded data: + +```typescript +const UserRoute = Route.make("/users/:id").pipe( + Route.params(Schema.Struct({ id: Schema.NumberFromString })), + Route.get( + ({ params: { id } }) => + Effect.gen(function* () { + const db = yield* DatabaseService; + return yield* db.getUser(id); + }), + (user) => UserPage({ user }), + ), +); +``` + +The first argument is the loader — it receives `{ params, searchParams }` and returns data. The second argument is the render function — it receives the loader's return value directly. + +The loader's error and requirement types (`E` and `R`) flow to the platform's HTTP router, not into the route's component types. This keeps client-side code clean of server dependencies. + +## Guards + +Protect routes with a reactive condition: + +```typescript +const DashboardRoute = Route.make("/dashboard").pipe( + Route.withGuard(isAuthenticated, { redirect: "/login" }), + Route.render(() => Dashboard()), +); +``` + +If `isAuthenticated` is a Readable that returns `false`, the user is redirected to `/login`. You can also provide a fallback component instead of a redirect: + +```typescript +Route.withGuard(isAuthenticated, { + fallback: () => $.div({}, $.of("Please log in")), +}) +``` + +## Animations + +Add enter/exit animations to route transitions: + +```typescript +const ModalRoute = Route.make("/modal/:id").pipe( + Route.withAnimation({ + enter: "slide-up", + exit: "slide-down", + }), + Route.render(() => ModalContent()), +); +``` + +These animations are applied by the Outlet when transitioning between routes. + +## Lazy Loading + +Split routes into separate bundles that load on demand: + +```typescript +const AdminRoute = Route.lazy( + "/admin", + () => import("./admin/AdminPage.js"), +); +``` + +The dynamic import runs when the route is first matched. The imported module must have a `default` export that is a Route. + +## Error Handling + +Catch errors from a route's render function: + +```typescript +const UserRoute = Route.make("/users/:id").pipe( + Route.get(loader, renderUser), + Route.catchTag("NotFound", () => NotFoundPage()), + Route.catchTag("Unauthorized", () => UnauthorizedPage()), +); + +// Or catch everything +const SafeRoute = Route.make("/risky").pipe( + Route.render(() => RiskyComponent()), + Route.catchAll((error) => ErrorPage({ error })), +); +``` diff --git a/apps/docs/content/05-router/01-building-a-router.md b/apps/docs/content/05-router/01-building-a-router.md new file mode 100644 index 00000000..4662b078 --- /dev/null +++ b/apps/docs/content/05-router/01-building-a-router.md @@ -0,0 +1,191 @@ +--- +title: "Building a Router" +description: "Compose routes into a router with fallbacks, layouts, guards, prefixes, and error handling." +order: 1 +--- + +# Building a Router + +A Router aggregates routes and adds cross-cutting concerns like layouts, guards, fallback pages, and error handling. You build one by starting with `Router.empty` and piping combinators. + +## Adding Routes + +Use `Router.concat` to add routes one at a time: + +```typescript +import { Router } from "@effex/router"; + +const router = Router.empty.pipe( + Router.concat(HomeRoute), + Router.concat(AboutRoute), + Router.concat(UserRoute), +); +``` + +`Router.concat` also accepts another Router, so you can compose sub-routers: + +```typescript +const adminRouter = Router.empty.pipe( + Router.concat(AdminDashboardRoute), + Router.concat(AdminUsersRoute), +); + +const router = Router.empty.pipe( + Router.concat(HomeRoute), + Router.concat(adminRouter), +); +``` + +## Fallback (404) + +Set a fallback component for when no route matches: + +```typescript +const router = Router.empty.pipe( + Router.concat(HomeRoute), + Router.concat(AboutRoute), + Router.fallback(() => NotFoundPage()), +); +``` + +The fallback renders whenever the current pathname doesn't match any route. + +## Prefixing + +Add a path prefix to all routes in a router. Useful for grouping related routes under a common path: + +```typescript +const adminRouter = Router.empty.pipe( + Router.concat(DashboardRoute), // "/dashboard" + Router.concat(UsersRoute), // "/users" + Router.prefixAll("/admin"), +); +// Routes: /admin/dashboard, /admin/users +``` + +## Layouts + +Wrap matched routes in a layout component. Layouts are applied inside-out — the first layout is innermost: + +```typescript +const dashboardRouter = Router.empty.pipe( + Router.concat(DashboardHomeRoute), + Router.concat(SettingsRoute), + Router.layout(SidebarLayout), + Router.layout(AppShell), +); +// Renders: AppShell(SidebarLayout(matchedRoute)) +``` + +A layout function receives the matched route's Element and must be transparent to its error and requirement types: + +```typescript +const SidebarLayout = ( + children: Element.Element, +) => + $.div( + { class: "flex" }, + collect( + Sidebar(), + $.main({ class: "flex-1" }, children), + ), + ); +``` + +The generic signature ensures that errors and context requirements from the matched route flow through the layout unchanged. + +## Router-Level Guards + +Protect an entire group of routes with a guard: + +```typescript +const protectedRouter = Router.empty.pipe( + Router.concat(DashboardRoute), + Router.concat(ProfileRoute), + Router.concat(SettingsRoute), +); + +const router = Router.empty.pipe( + Router.concat(HomeRoute), + Router.concat(LoginRoute), + Router.guard(isAuthenticated, protectedRouter, { + redirect: "/login", + }), +); +``` + +When `isAuthenticated` is `false`, any attempt to navigate to a protected route redirects to `/login`. The guard accepts either a `Readable` or an `Effect`. + +You can also render a fallback instead of redirecting: + +```typescript +Router.guard(isAuthenticated, protectedRouter, { + fallback: () => LoginPage(), +}); +``` + +## Router-Level Error Handling + +Catch errors from all routes in the router: + +```typescript +const router = Router.empty.pipe( + Router.concat(HomeRoute), + Router.concat(UserRoute), + Router.catchTag("NotFound", () => NotFoundPage()), + Router.catchTag("Unauthorized", () => LoginPage()), +); +``` + +This is equivalent to adding `Route.catchTag` to every individual route, but applied in one place. + +### Other Error Handlers + +```typescript +// Catch by predicate +Router.catchIf( + (e) => e._tag === "NetworkError", + () => OfflinePage(), +); + +// Catch everything +Router.catchAll((error) => ErrorPage({ error })); +``` + +## Full Example + +```typescript +import { Router } from "@effex/router"; + +// Public routes +const publicRouter = Router.empty.pipe( + Router.concat(HomeRoute), + Router.concat(LoginRoute), + Router.concat(SignupRoute), +); + +// Admin section with its own layout and prefix +const adminRouter = Router.empty.pipe( + Router.concat(AdminDashboardRoute), + Router.concat(AdminUsersRoute), + Router.prefixAll("/admin"), + Router.layout(AdminLayout), +); + +// Protected routes +const appRouter = Router.empty.pipe( + Router.concat(DashboardRoute), + Router.concat(ProfileRoute), + Router.concat(SettingsRoute), + Router.layout(AppLayout), +); + +// Compose everything +export const router = Router.empty.pipe( + Router.concat(publicRouter), + Router.guard(isAuthenticated, appRouter, { redirect: "/login" }), + Router.guard(isAdmin, adminRouter, { redirect: "/" }), + Router.fallback(() => NotFoundPage()), + Router.catchAll((error) => ErrorPage({ error })), +); +``` diff --git a/apps/docs/content/05-router/02-navigation.md b/apps/docs/content/05-router/02-navigation.md new file mode 100644 index 00000000..d8932381 --- /dev/null +++ b/apps/docs/content/05-router/02-navigation.md @@ -0,0 +1,202 @@ +--- +title: "Navigation" +description: "Navigate programmatically and read route state with the Navigation service." +order: 2 +--- + +# Navigation + +The Navigation service manages browser history and exposes reactive state for the current URL. It's provided as an Effect context via `Navigation.makeLayer` and consumed in components via accessor effects or the `NavigationContext` tag. + +## Setting Up + +Provide the Navigation layer at your app's root: + +```typescript +import { Navigation } from "@effex/router"; +import { runApp, mount } from "@effex/dom"; +import { Effect } from "effect"; + +runApp( + Effect.gen(function* () { + yield* mount(App(), document.getElementById("root")!); + }), + { layer: Navigation.makeLayer(router) }, +); +``` + +Once provided, any component in the tree can navigate or read the current route. + +## Programmatic Navigation + +### Type-Safe Route Navigation + +Use `Navigation.pushRoute` to navigate with type-safe params: + +```typescript +import { Navigation } from "@effex/router"; + +// Params are inferred from the route definition +yield* Navigation.pushRoute(UserRoute, { + params: { id: 123 }, +}); + +// With search params +yield* Navigation.pushRoute(SearchRoute, { + params: {}, + searchParams: { q: "effect", page: 2 }, +}); + +// Replace instead of push (no new history entry) +yield* Navigation.replaceRoute(UserRoute, { + params: { id: 456 }, +}); +``` + +The type safety comes from the Route object — if `UserRoute` has `params: Schema.Struct({ id: Schema.NumberFromString })`, TypeScript enforces that you pass `{ id: number }`. + +### Path-Based Navigation + +When you don't need typed params: + +```typescript +yield* Navigation.pushPath("/users/123"); +yield* Navigation.replacePath("/login"); +``` + +### History + +```typescript +yield* Navigation.back; +yield* Navigation.forward; +``` + +## Reading Route State + +All route state is reactive — bind it directly to your UI. + +### Current Pathname + +```typescript +const path = yield* Navigation.pathname; +// "string" — one-time read + +// Or access the Readable for reactive binding: +const nav = yield* NavigationContext; +nav.pathname; // Readable +``` + +### Search Params + +```typescript +const params = yield* Navigation.searchParams; +// URLSearchParams — one-time read +``` + +### Current Match + +```typescript +const match = yield* Navigation.currentMatch; +// { route, params } — the currently matched route and raw params +``` + +## Link Component + +For declarative navigation in the UI, use the `Link` component: + +```typescript +import { Link } from "@effex/router"; +import { $ } from "@effex/dom"; + +// Path-based +Link({ href: "/users" }, $.of("Users")); + +// Type-safe route-based +Link( + { to: UserRoute, params: { id: 123 } }, + $.of("View User"), +); + +// With search params +Link( + { href: "/search", searchParams: { q: "test" } }, + $.of("Search"), +); + +// Replace instead of push +Link( + { href: "/settings", replace: true }, + $.of("Settings"), +); + +// External links work normally +Link( + { href: "https://example.com", target: "_blank" }, + $.of("External"), +); +``` + +### Active State + +Link automatically sets data attributes based on the current pathname: + +- `data-active-exact="true"` — when the href matches the current path exactly +- `data-active-prefix="true"` — when the current path starts with the href + +Style active links with CSS: + +```css +a[data-active-exact] { + font-weight: bold; +} + +a[data-active-prefix] { + color: var(--primary); +} +``` + +### Standard Anchor Behavior + +Link renders a real `` element. Modified clicks (Ctrl+click, Cmd+click, middle-click) work normally — they open in a new tab. Only plain left-clicks are intercepted for SPA navigation. + +## Outlet + +The `Outlet` component renders the currently matched route: + +```typescript +import { Outlet } from "@effex/router"; +import { $ } from "@effex/dom"; + +const App = () => + $.div( + { class: "app" }, + collect( + Header(), + $.main({}, Outlet({ router })), + Footer(), + ), + ); +``` + +Outlet reads from `NavigationContext`, matches the current pathname against the router's routes, and renders the matched route's component. When the URL changes, the previous route is unmounted and the new one is rendered. + +### With Animations + +```typescript +Outlet({ + router, + animate: { + enterFrom: "opacity-0", + enter: "transition-opacity duration-150", + enterTo: "opacity-100", + exit: "transition-opacity duration-150", + exitTo: "opacity-0", + }, +}); +``` + +The exiting route animates out, then the entering route animates in. + +### Guards and Layouts + +Outlet handles guards and layouts automatically. If a matched route has a guard that returns `false`, the guard's redirect or fallback is used. Layouts wrap the matched route inside-out, as configured on the Router. diff --git a/apps/docs/content/06-platform/00-introduction.md b/apps/docs/content/06-platform/00-introduction.md new file mode 100644 index 00000000..5a57e33f --- /dev/null +++ b/apps/docs/content/06-platform/00-introduction.md @@ -0,0 +1,98 @@ +--- +title: "Platform Overview" +description: "How @effex/platform integrates with @effect/platform instead of reinventing the wheel." +order: 0 +--- + +# Platform Overview + +## Not a Meta-Framework + +Most frontend libraries ship their own server runtime. Next.js has its Node server. Nuxt has Nitro. SvelteKit has its adapter system. Each one reinvents routing, request handling, middleware, and deployment — on top of what the framework already provides. + +Effex doesn't do this. + +`@effex/platform` is a thin integration layer between Effex's router and [Effect's HTTP platform](https://effect-ts.github.io/effect/platform/HttpRouter.ts.html). It doesn't have its own server, its own request pipeline, or its own deployment story. Instead, it gives you a function — `Platform.toHttpRoutes` — that converts your Effex Router into an `@effect/platform` HttpRouter. From there, you use Effect's existing HTTP infrastructure to serve requests however you want. + +## Why This Matters + +Effect already has a production-grade HTTP stack: + +- **HttpRouter** for declarative route registration +- **HttpServerRequest / HttpServerResponse** for typed request/response handling +- **HttpServer** with adapters for Node, Bun, and more +- **Middleware**, **error handling**, and **observability** built in +- Full **Layer-based dependency injection** + +Rebuilding any of this would be a waste. Instead, `toHttpRoutes` produces an HttpRouter that plugs into whatever Effect HTTP setup you already have: + +```typescript +import { HttpRouter } from "@effect/platform"; +import { Platform } from "@effex/platform"; + +// Your Effex routes become Effect HTTP routes +const effexRoutes = Platform.toHttpRoutes(router, { + app: App, + document: { title: "My App", scripts: ["/client.js"] }, +}); + +// Compose with your own API routes, middleware, etc. +const httpApp = HttpRouter.empty.pipe( + HttpRouter.concat(apiRoutes), + HttpRouter.concat(effexRoutes), +); +``` + +This means you can: + +- **Add API routes** alongside your Effex pages using the same HttpRouter +- **Use Effect middleware** for auth, logging, rate limiting — things that already exist in the ecosystem +- **Deploy anywhere** Effect's HTTP platform runs — Node, Bun, Cloudflare Workers, or any custom adapter +- **Share services** between your API and your page loaders via Effect's Layer system + +## What @effex/platform Actually Does + +The package provides three utilities: + +### `Platform.toHttpRoutes(router, options)` + +Converts your Effex Router into an HttpRouter. For each route: + +- **GET** → runs the loader, SSR renders the page (or returns JSON for `?_data=1` client navigations) +- **POST/PUT/DELETE** → dispatches to mutation handlers by `?_action=key` + +### `Platform.makeClientLayer(router)` + +Creates a client-side Layer for hydration. On first load, reads embedded data from the server. On subsequent navigations, fetches data via `?_data=1`. + +### `Platform.buildStaticSite(options)` + +Pre-renders all `Route.static` routes to HTML files at build time. Used by the Vite plugin for SSG. + +## The Vite Plugin + +`@effex/vite-plugin` handles the dev-time and build-time integration: + +- **Dev mode** — SSR dev server with HMR +- **Client builds** — strips server-only code (loaders, handlers) from the browser bundle +- **SSG builds** — runs `buildStaticSite` after the SSR build + +The plugin is the only piece with opinions about tooling. Everything else is just Effect. + +## When You Don't Need Platform + +If you're building a pure SPA — no server rendering, no server-side data loading — you don't need `@effex/platform` at all. Use `Navigation.makeLayer(router)` directly and run loaders client-side: + +```typescript +import { runApp, mount } from "@effex/dom"; +import { Navigation } from "@effex/router"; + +runApp( + Effect.gen(function* () { + yield* mount(App(), document.getElementById("root")!); + }), + { layer: Navigation.makeLayer(router) }, +); +``` + +Platform is for when you want the server involved — SSR, SSG, or server-side data loading. diff --git a/apps/docs/content/06-platform/01-server-side-rendering.md b/apps/docs/content/06-platform/01-server-side-rendering.md new file mode 100644 index 00000000..d632435a --- /dev/null +++ b/apps/docs/content/06-platform/01-server-side-rendering.md @@ -0,0 +1,200 @@ +--- +title: "Server-Side Rendering" +description: "Convert an Effex Router into an Effect HttpRouter with SSR, data loading, and mutation handling." +order: 1 +--- + +# Server-Side Rendering + +`@effex/platform` bridges Effex's router to Effect's HTTP platform. The core function, `Platform.toHttpRoutes`, converts your Effex Router into an `@effect/platform` HttpRouter that handles SSR rendering, data loading, and mutation execution. + +## Setup + +```typescript +import { Effect } from "effect"; +import { HttpRouter, HttpServer } from "@effect/platform"; +import { Platform } from "@effex/platform"; + +import { App } from "./app.js"; +import { router } from "./routes.js"; + +const effexRoutes = Platform.toHttpRoutes(router, { + app: App, + document: { + title: "My App", + scripts: ["/client.js"], + styles: ["/styles.css"], + }, +}); + +const httpApp = HttpRouter.empty.pipe( + HttpRouter.concat(effexRoutes), +); +``` + +`toHttpRoutes` registers handlers for every route in your router. You can compose the result with other `@effect/platform` routes — adding API endpoints, static file serving, or anything else. + +## How Requests Are Handled + +### GET Requests + +For each route, the GET handler: + +1. Extracts and validates URL params and search params against the route's schemas +2. Runs the route's loader (`Route.get`) if one exists +3. If the request has `?_data=1`, returns the loader data as JSON (for client-side navigation) +4. Otherwise, SSR renders the component tree and returns a full HTML document + +The HTML document includes the rendered content, embedded loader data for hydration, and script/style tags from the document options. + +### POST / PUT / DELETE Requests + +Mutation handlers execute directly — no component rendering: + +1. Reads `?_action=key` from the URL to find the matching handler +2. Parses the request body (JSON or URL-encoded) +3. Runs the handler and returns the result as JSON + +This means `Route.post("updateProfile", handler)` creates a POST endpoint at the route's path that dispatches by action key. + +## Mutation Handlers + +Routes can define handlers for POST, PUT, and DELETE requests using `Route.post`, `Route.put`, and `Route.delete`. These are server-only — the platform executes them directly without rendering any components: + +```typescript +const UserRoute = Route.make("/users/:id").pipe( + Route.params(Schema.Struct({ id: Schema.NumberFromString })), + Route.post("updateProfile", (body) => + Effect.gen(function* () { + const data = yield* Schema.decodeUnknown(ProfileSchema)(body); + const db = yield* DatabaseService; + return yield* db.updateProfile(data); + }), + ), + Route.put("updateSettings", (body) => + Effect.gen(function* () { + const settings = yield* Schema.decodeUnknown(SettingsSchema)(body); + const db = yield* DatabaseService; + return yield* db.updateSettings(settings); + }), + ), + Route.delete("deleteUser", (_body) => + Effect.gen(function* () { + const db = yield* DatabaseService; + return yield* db.deleteUser(); + }), + ), + Route.get(loader, (user) => UserPage({ user })), +); +``` + +Each handler has a unique key (like `"updateProfile"`). The platform generates action URLs from these keys and makes them available to your components via `RouteDataContext`: + +```typescript +const UserPage = (props: { user: User }) => + Effect.gen(function* () { + const { actions } = yield* RouteDataContext; + // actions.updateProfile → "/users/123?_action=updateProfile" + + return yield* $.form( + { action: actions.updateProfile, method: "POST" }, + collect( + $.input({ name: "name", value: props.user.name }), + $.button({ type: "submit" }, $.of("Save")), + ), + ); + }); +``` + +In client builds, the Vite plugin strips the handler bodies so server-only dependencies don't end up in the browser bundle. The keys are preserved so the Outlet can still compute action URLs. + +### Redirects + +Throw a `RedirectError` from any loader or handler to trigger an HTTP redirect: + +```typescript +import { Platform } from "@effex/platform"; + +Route.get( + ({ params }) => + Effect.gen(function* () { + const user = yield* db.getUser(params.id); + if (!user) { + return yield* Effect.fail( + new Platform.RedirectError({ url: "/not-found", status: 302 }), + ); + } + return user; + }), + (user) => UserPage({ user }), +); +``` + +For data requests (`?_data=1`), redirects are returned as JSON signals (`{ _redirect: url }`) so the client can handle them as SPA navigations instead of full page reloads. + +## Document Options + +The `document` option controls the HTML shell: + +```typescript +Platform.toHttpRoutes(router, { + document: { + title: "My App", + scripts: ["/assets/client.js"], + styles: ["/assets/styles.css"], + head: '', + htmlAttrs: { lang: "en", "data-theme": "dark" }, + }, +}); +``` + +| Option | Description | +|--------|-------------| +| `title` | Content of the `` tag | +| `scripts` | Script URLs added as `<script type="module">` tags | +| `styles` | Stylesheet URLs added as `<link rel="stylesheet">` tags | +| `head` | Raw HTML injected into `<head>` | +| `htmlAttrs` | Attributes added to the `<html>` element | + +## App Component + +The `app` option specifies your root component — the same tree the client hydrates. It should contain an `Outlet` that renders the matched route: + +```typescript +const App = () => + $.div( + { class: "app" }, + collect( + Header(), + $.main({}, Outlet({ router })), + Footer(), + ), + ); + +Platform.toHttpRoutes(router, { app: App }); +``` + +If `app` is omitted, the platform renders just the matched route with its layouts applied. Using `app` ensures the server-rendered HTML matches what the client hydrates. + +## Client Hydration + +On the client, use `Platform.makeClientLayer` to set up navigation and data fetching: + +```typescript +import { hydrate } from "@effex/dom/hydrate"; +import { Platform } from "@effex/platform"; + +import { App } from "./app.js"; +import { router } from "./routes.js"; + +hydrate(App(), document.getElementById("root")!, { + layers: Platform.makeClientLayer(router), +}); +``` + +`makeClientLayer` provides two things: + +1. **NavigationContext** — browser history management and route matching +2. **RouteDataProvider** — on the first load (hydration), reads data from `window.__EFFEX_DATA__` embedded by the server. On subsequent navigations, fetches from the server via `?_data=1` + +After hydration, all navigation is client-side. Only data is fetched from the server — no full page reloads. diff --git a/apps/docs/content/06-platform/02-static-site-generation.md b/apps/docs/content/06-platform/02-static-site-generation.md new file mode 100644 index 00000000..cfd6e2c3 --- /dev/null +++ b/apps/docs/content/06-platform/02-static-site-generation.md @@ -0,0 +1,175 @@ +--- +title: "Static Site Generation" +description: "Pre-render pages at build time with Route.static and Platform.buildStaticSite." +order: 2 +--- + +# Static Site Generation + +SSG pre-renders pages to HTML at build time. No server at runtime — just static files you can deploy anywhere. Use it for docs sites, blogs, marketing pages, or anything where the content is known ahead of time. + +## Defining Static Routes + +Use `Route.static` to declare which paths to generate and how to load data for each: + +```typescript +import { Effect } from "effect"; +import { Schema } from "effect"; +import { Route } from "@effex/router"; + +const BlogRoute = Route.make("/blog/:slug").pipe( + Route.params(Schema.Struct({ slug: Schema.String })), + Route.static({ + // Enumerate all pages to generate + paths: () => + Effect.succeed([ + { slug: "hello-world" }, + { slug: "getting-started" }, + { slug: "advanced-patterns" }, + ]), + + // Load data for each page (runs at build time) + load: ({ params }) => + Effect.gen(function* () { + const content = yield* readMarkdown(`blog/${params.slug}.md`); + return { title: content.title, html: content.html }; + }), + + // Render with loaded data + render: (data) => BlogPost(data), + }), +); +``` + +`paths` returns an array of param objects — one per page to generate. `load` fetches data for each page. `render` produces the component. All three run at build time. + +On the client, the Vite plugin strips `paths` and `load` from the bundle — only `render` ships to the browser. + +## Building the Site + +`Platform.buildStaticSite` runs the SSG build programmatically: + +```typescript +import { Platform } from "@effex/platform"; + +await Platform.buildStaticSite({ + router, + app: App, + document: { + title: "My Blog", + scripts: ["/assets/client.js"], + styles: ["/assets/styles.css"], + }, + outDir: "dist", +}); +``` + +This: +1. Finds all routes with `Route.static` config +2. Calls `paths()` to enumerate param sets +3. Calls `load()` for each set to fetch data +4. Renders each page to HTML through the `app` component (or just the route if `app` is omitted) +5. Writes the HTML files to `outDir` + +### Output Structure + +Each page becomes an `index.html` at its URL path: + +``` +dist/ +├── index.html ← / +├── blog/ +│ ├── hello-world/ +│ │ └── index.html ← /blog/hello-world +│ ├── getting-started/ +│ │ └── index.html ← /blog/getting-started +│ └── advanced-patterns/ +│ └── index.html ← /blog/advanced-patterns +└── 404.html ← from Router.fallback +``` + +If your router has a `Router.fallback`, a `404.html` is generated automatically. + +### Providing Services + +If your loaders depend on Effect services (filesystem, markdown parser, database, etc.), pass them via `layers`: + +```typescript +import { Layer } from "effect"; + +await Platform.buildStaticSite({ + router, + outDir: "dist", + layers: Layer.mergeAll( + FileSystemLive, + MarkdownServiceLive, + ), +}); +``` + +## Vite Integration + +In practice, you don't call `buildStaticSite` directly. The Vite plugin handles it as part of the build. + +### Entry Point + +Create an SSG entry that exports the router, app component, and document options: + +```typescript +// src/entry.ts +import { App } from "./app.js"; +import { router } from "./routes.js"; + +export { router }; +export const app = App; +export const document = { + title: "My Blog", + scripts: ["/assets/client.js"], + styles: ["/assets/styles.css"], +}; +``` + +### Vite Config + +```typescript +// vite.config.ts +import { defineConfig } from "vite"; +import { effexPlatform } from "@effex/vite-plugin"; + +export default defineConfig({ + plugins: [ + effexPlatform({ mode: "ssg", entry: "src/entry.ts" }), + ], +}); +``` + +### Build Command + +```bash +vite build && vite build --ssr src/entry.ts +``` + +The first command builds the client bundle. The second builds the SSR entry, and the Vite plugin's `closeBundle` hook runs `buildStaticSite` automatically using the compiled entry. + +## Client Hydration + +Static pages are hydrated on the client just like SSR pages. The client entry provides the Navigation layer: + +```typescript +// src/client.ts +import { Effect } from "effect"; +import { hydrate } from "@effex/dom/hydrate"; +import { Navigation } from "@effex/router"; + +import { App } from "./app.js"; +import { router } from "./routes.js"; + +const navLayer = Navigation.makeLayer(router); + +hydrate( + Effect.provide(App(), navLayer), + document.getElementById("root")!, +); +``` + +After hydration, clicking a Link triggers client-side navigation — the browser doesn't reload the page. diff --git a/apps/docs/content/06-platform/03-vite-plugin.md b/apps/docs/content/06-platform/03-vite-plugin.md new file mode 100644 index 00000000..a8663a31 --- /dev/null +++ b/apps/docs/content/06-platform/03-vite-plugin.md @@ -0,0 +1,161 @@ +--- +title: "Vite Plugin" +description: "Dev server SSR, server-code stripping, and SSG build integration with @effex/vite-plugin." +order: 3 +--- + +# Vite Plugin + +`@effex/vite-plugin` provides three capabilities: + +1. **SSR dev server** — intercepts requests in dev mode, renders pages with HMR +2. **Server-code stripping** — removes loader and handler bodies from client builds +3. **SSG build hook** — runs `buildStaticSite` after the SSR build completes + +You only need this plugin when using `@effex/platform` for SSR or SSG. Pure SPAs don't need it. + +## Installation + +```bash +pnpm add -D @effex/vite-plugin +``` + +## Configuration + +```typescript +// vite.config.ts +import { defineConfig } from "vite"; +import { effexPlatform } from "@effex/vite-plugin"; + +export default defineConfig({ + plugins: [ + effexPlatform({ + entry: "src/entry.ts", + mode: "ssr", // or "ssg" + }), + ], +}); +``` + +### Options + +| Option | Type | Default | Description | +|--------|------|---------|-------------| +| `entry` | `string` | — | Path to the SSR/SSG entry module | +| `mode` | `"ssr" \| "ssg"` | `"ssr"` | Build mode | +| `include` | `RegExp` | `/\.(tsx?\|jsx?)$/` | Files to apply server-code stripping to | +| `exclude` | `RegExp` | — | Files to exclude from stripping | + +## SSR Dev Server + +When `entry` is provided and Vite is in dev mode, the plugin intercepts incoming requests and renders them through your entry module: + +```typescript +// src/entry.ts (SSR mode) +import { HttpApp, HttpRouter } from "@effect/platform"; +import { Platform } from "@effex/platform"; + +import { App } from "./app.js"; +import { router } from "./routes.js"; + +const effexRoutes = Platform.toHttpRoutes(router, { + app: App, + document: { title: "My App", scripts: ["/src/client.ts"] }, +}); + +const httpApp = HttpRouter.empty.pipe(HttpRouter.concat(effexRoutes)); +const handler = HttpApp.toWebHandler(httpApp); + +export async function render(request: Request): Promise<Response> { + return handler(request); +} +``` + +The entry must export a `render` function that takes a Web Request and returns a Web Response. The plugin: + +1. Converts the incoming Node request to a Web Request +2. Calls your `render` function via `vite.ssrLoadModule` (with HMR) +3. Injects Vite's HMR client into HTML responses +4. Sends the response back to the browser + +Static assets, Vite internal requests, and source files are passed through to Vite's normal middleware. + +## Server-Code Stripping + +In client builds, the plugin removes server-only code so it doesn't end up in the browser bundle: + +| Source | Client build output | +|--------|-------------------| +| `Route.get(loader, render)` | `Route.get(null, render)` | +| `Route.post("key", handler)` | `Route.post("key", () => { throw new Error("server only"); })` | +| `Route.put("key", handler)` | Same as post | +| `Route.delete("key", handler)` | Same as post | +| `Route.static({ paths, load, render })` | `Route.render(() => (render)(undefined))` | + +This means your loader functions can import server-only dependencies (database clients, filesystem, etc.) without worrying about them being bundled into the client. The plugin also removes import declarations that become unused after stripping. + +Stripping only happens in **client builds** — SSR builds and dev mode keep the full code. + +## SSG Build Hook + +In SSG mode, the plugin runs `Platform.buildStaticSite` after the SSR build completes: + +```typescript +effexPlatform({ mode: "ssg", entry: "src/entry.ts" }) +``` + +The build sequence is: + +1. `vite build` — client bundle (stripping applied, SSG hook skipped) +2. `vite build --ssr src/entry.ts` — SSR bundle (no stripping, SSG hook runs) + +The hook imports the compiled SSR entry from the output directory and calls `buildStaticSite` with the exported `router`, `app`, `document`, and `layers`. + +### SSG Entry Point + +```typescript +// src/entry.ts (SSG mode) +import { App } from "./app.js"; +import { router } from "./routes.js"; + +export { router }; +export const app = App; +export const document = { + title: "My Site", + scripts: ["/assets/client.js"], + styles: ["/assets/styles.css"], +}; + +// Optional: services needed by loaders at build time +export { layers } from "./services.js"; +``` + +The entry can also export a `render` function for the dev server — both SSR and SSG entries work in dev mode. + +## Build Scripts + +### SSR + +```json +{ + "scripts": { + "dev": "vite", + "build": "vite build && vite build --ssr src/entry.ts", + "start": "node dist/server.js" + } +} +``` + +### SSG + +```json +{ + "scripts": { + "dev": "vite", + "build": "vite build && vite build --ssr src/entry.ts", + "preview": "vite preview" + } +} +``` + +Both modes use the same two-step build. The difference is what happens after: SSR deploys a server, SSG deploys static files. diff --git a/apps/docs/content/effect-in-2-minutes.md b/apps/docs/content/effect-in-2-minutes.md index a27e8a7f..41360fec 100644 --- a/apps/docs/content/effect-in-2-minutes.md +++ b/apps/docs/content/effect-in-2-minutes.md @@ -1,14 +1,15 @@ --- title: "Effect in 2 Minutes" description: "A quick mental model for Effect.ts—think Promises with superpowers" -order: 1 +order: 2 --- # Effect in 2 Minutes You don't need to be an Effect expert to use Effex. This page gives you just enough to be productive. -## The Mental Model: Promises with Superpowers +## The Mental Model +### Promises with Superpowers If you know Promises, you already understand 80% of Effect. @@ -21,7 +22,8 @@ If you know Promises, you already understand 80% of Effect. The key difference: Effect tracks **errors** and **requirements** in the type system. -## Pipeline Style: .then → .pipe +## Pipeline Style +### .then → .pipe You can chain Promises with `.then()`. Effect uses `.pipe()` with operators: @@ -48,7 +50,8 @@ fetchUser(id).pipe( The difference? With Effect, **errors are typed**. The compiler knows exactly what can fail. -## Generator Style: async/await → Effect.gen/yield* +## Generator Style +### async/await → Effect.gen/yield* Just like `async/await` made Promise chains readable, `Effect.gen` does the same: @@ -167,7 +170,8 @@ Effect.runPromise( This makes testing trivial—swap `DatabaseLive` for `DatabaseTest` with no code changes. -## Effect.gen: Where You'll See This Most +## Effect.gen +### Where You'll See This Most In Effex, you'll mostly use `Effect.gen`: diff --git a/apps/docs/content/introduction.md b/apps/docs/content/introduction.md new file mode 100644 index 00000000..9312dfd5 --- /dev/null +++ b/apps/docs/content/introduction.md @@ -0,0 +1,104 @@ +--- +title: "Introduction" +description: "Welcome to Effex! This introduction gives an overview of what Effex is and how it works." +order: 0 +--- + +# Introduction + +<span class="text-sm">Not a big reader? Fast-forward to the [TL;DR](#tl-dr)</span> + +## What is Effex? + +### The frontend for Effect.ts + +Effex is a reactive UI library ecosystem built on top of [Effect.ts](https://effect.website). +If you're a fan of Effect for the backend, you'll love Effex for the frontend. It boasts +poweful features like type safety, composability, and a functional programming style that +lets you write clean, maintainable, robust code. It's intended to catch mistakes at +compile time, rather than having runtime surprises. + +### Composability and Primitives + +Effex is built using simple, but powerful primitives, and then composes to enable emergent +functionality that would be a spaghetti mess otherwise. This follow the philosophy that makes +Effect so powerful and flexible. Under the hood, most of Effex primitives are themselves Effects, +so Effex seamlessly integrates with Effect. In fact, our +[meta-framework](https://github.com/jonlaing/effex/tree/main/packages/platform) is just a +thin layer to convert the Effex [router](https://github.com/jonlaing/effex/tree/main/packages/router) +into an Effect [HttpRouter](https://effect-ts.github.io/effect/platform/HttpRouter.ts.html). +Effex isn't intended to reinvent the wheel, but to stand on the shoulders of giants. + +### Designed to be Used Anywhere + +Additionally, most of Effex is environment agnostic. Currently, we're only targeting the web +([`@effex/dom`](https://github.com/jonlaing/effex/tree/main/packages/dom) and +[`@effex/platform`](https://github.com/jonlaing/effex/tree/main/packages/platform)), but +the rest of the library is designed to be used in any JavaScript environment. In the future, +we hope to support other environments like mobile and desktop, and even non-UI environments +like rich CLI tools. + +## What Effex is *Not* + +### The React Killer + +Effex is not an indictment on any other frontend library or framework. It wasn't made +out of frustration or thinking we could do it better than anyone else. Effex was written +as a love letter to [Effect.ts](https://effect.website) and out of a deep desire to be +able to write Effectful code for the frontend. There are others making great libraries +that integrate with other existing frontend frameworks, like +[Lucas Barake](https://github.com/lucas-barake), but we wanted the full power of Effect +all the way through the application. So we had to build our own from the ground up. + +### The Best Choice for Every Project + +Additionally, Effex is admittedly *not* the best choice for every project. It has very +strict type safety and a functional programming style that may not be everyone's cup of tea. +The strictness may also make it cumbersome for quick prototypes or small projects. +Also, since it's build on top of Effect, some may find the learning curve to be steeper +than other libraries. Effex shines when correctness and maintainability are top priorities, +when it's worth investing in the learning curve, and when you want to leverage the full power +of Effect. Effex is meant for production-grade applications where robustness and maintainability +are key. + +## Core Concepts + +### Reactivity + +Effex does not have a virtual DOM like React. Instead, it uses a fine-grained reactivity +system built on top of Effect's [`SubscriptionRef`](https://effect-ts.github.io/effect/SubscriptionRef.ts.html) +to track dependencies and update the DOM via [`Stream`](https://effect-ts.github.io/effect/Stream.ts.html) updates. +This allows for surgical updates to the DOM, rather than re-rendering entire components. + +### Type Safety + +Effex is designed to catch mistakes at compile time. It leverages TypeScript's type system to ensure +that your code is correct and that you won't run into unexpected runtime errors. For instance, every +element in an Effex app has the type `Element.Element<A, E, R>`, which encodes the type of the +element (usually HTMLElement) in the `A` channel, the errors it may produce in the `E` channel, and +the dependencies it requires in the `R` channel. Your components describe themselves in their type +signature, and the compiler checks that you use them correctly. + +## How Effex Was Built + +### AI for Speed, Humans for Quality + +Effex was first conceived in late 2025; as such, LLMs played a large role in its creation. +It started as just a conversation with an AI to explore whether the idea would even work. +From there, a proof-of-concept was built almost entirely vibe-coded. But that's all it was: +a proof-of-concept. After the idea was validated, then came the real work of building a +production-ready library. Each package was entirely rewritten. In fact, with the addition of +strong human oversight, 2/3rds of the code was deleted without sacrificing functionality. +AI was still used, but it was working off of detailed human-authored design docs +and specifications. We kept the robot on a tight leash! + +<a id="tl-dr"></a> +## TL;DR + +- Effex is a reactive UI library ecosystem built on top of Effect.ts +- It offers powerful features like type safety, composability, and a functional programming style +- Effex is designed to catch mistakes at compile time, rather than having runtime surprises +- Effex is built using simple, but powerful primitives, and then composes to enable emergent functionality +- Effex is environment agnostic, currently targeting the web but designed to be used in any JavaScript environment +- Effex is not an indictment on any other frontend library or framework, but was built out of a desire to write Effectful code for the frontend +- Effex may not be the best choice for every project, but shines when correctness and maintainability are top priorities diff --git a/apps/docs/content/quick-start.md b/apps/docs/content/quick-start.md new file mode 100644 index 00000000..5ada0698 --- /dev/null +++ b/apps/docs/content/quick-start.md @@ -0,0 +1,329 @@ +--- +title: "Quick Start" +description: "Set up a new Effex project as an SPA, SSR app, or static site in under a minute." +order: 1 +--- + +# Quick Start + +The fastest way to start an Effex project is with `create-effex`. It scaffolds a working app with routing, reactive state, and all the tooling configured. + +```bash +pnpm create effex my-app +``` + +You can also use npm, yarn, or bun: + +```bash +npx create-effex my-app +yarn create effex my-app +bunx create-effex my-app +``` + +The CLI will ask you to pick a template: + +- **SPA** — Client-side only, no server required +- **SSR** — Server-side rendering with client hydration +- **SSG** — Static site generation, pre-rendered at build time + +Once it's done, start the dev server: + +```bash +cd my-app +pnpm dev +``` + +Open [http://localhost:3000](http://localhost:3000) and you're running. + +The rest of this guide walks through what each template gives you and when to pick one over another. + +--- + +## SPA (Single Page Application) + +```bash +pnpm create effex my-app --spa +``` + +The simplest setup. Everything runs in the browser — no server, no build-time rendering. Good for dashboards, internal tools, or anything that doesn't need SEO. + +### What you get + +``` +my-app/ +├── src/ +│ ├── main.ts # Mounts the app +│ ├── App.ts # Root component with nav + Outlet +│ └── routes.ts # Route definitions +├── public/ +│ └── styles.css # Base styles +├── index.html +├── vite.config.ts +└── tsconfig.json +``` + +### Entry point + +`src/main.ts` mounts the app and provides the router's Navigation layer: + +```typescript +import { Effect } from "effect"; +import { mount, runApp } from "@effex/dom"; +import { Navigation } from "@effex/router"; + +import { App } from "./App.js"; +import { router } from "./routes.js"; + +const root = document.getElementById("root")!; + +runApp( + Effect.gen(function* () { + const app = yield* App(); + yield* mount(app, root); + }).pipe(Effect.provide(Navigation.layer(router))), +); +``` + +### Defining routes + +Routes are plain functions that return Effects: + +```typescript +import { Route, Router } from "@effex/router"; +import { $, collect, Signal } from "@effex/dom"; + +const Home = Route.make("/").pipe( + Route.render(() => + Effect.gen(function* () { + const count = yield* Signal.make(0); + return yield* $.div( + {}, + collect( + $.h1({}, $.of("Welcome to Effex")), + $.button( + { onClick: () => count.update((n) => n + 1) }, + count, + ), + ), + ); + }), + ), +); + +export const router = Router.empty.pipe( + Router.concat(Home), +); +``` + +### Scripts + +| Command | What it does | +|---------|-------------| +| `pnpm dev` | Start Vite dev server | +| `pnpm build` | Production build | +| `pnpm preview` | Preview the production build | + +--- + +## SSR (Server-Side Rendering) + +```bash +pnpm create effex my-app --ssr +``` + +Full-stack rendering. The server renders HTML on each request using Effect's HTTP platform, then the client hydrates it. Use this when you need SEO, fast initial page loads, or server-side data loading. + +### What you get + +``` +my-app/ +├── src/ +│ ├── app.ts # Root component +│ ├── routes.ts # Route definitions +│ ├── server.ts # Production Node.js server +│ ├── client.ts # Client hydration entry +│ └── vite-entry.ts # Dev server SSR entry +├── public/ +│ └── styles.css +├── vite.config.ts +└── tsconfig.json +``` + +### Server entry + +`src/server.ts` is a Node.js server built on `@effect/platform`. It serves static assets and delegates everything else to Effex's SSR: + +```typescript +import { Effect } from "effect"; +import { HttpRouter, HttpServer } from "@effect/platform"; +import { NodeHttpServer, NodeRuntime } from "@effect/platform-node"; +import { Platform } from "@effex/platform"; + +import { App } from "./app.js"; +import { router } from "./routes.js"; + +const effexRoutes = Platform.toHttpRoutes(router, { + app: App, + document: { + title: "My App", + scripts: ["/client.js"], + styles: ["/styles.css"], + }, +}); + +// Compose with other routes if needed +const httpApp = HttpRouter.empty.pipe( + HttpRouter.concat(effexRoutes), +); +``` + +### Client hydration + +`src/client.ts` hydrates the server-rendered HTML: + +```typescript +import { hydrate } from "@effex/dom"; +import { Platform } from "@effex/platform"; + +import { App } from "./app.js"; +import { router } from "./routes.js"; + +hydrate(App(), document.getElementById("root")!, { + layers: Platform.makeClientLayer(router), +}); +``` + +After hydration, navigation is client-side. Data for new pages is fetched as JSON (via `?_data=1` requests) without full page reloads. + +### Vite plugin + +The SSR template uses `@effex/vite-plugin` to handle dev-time SSR: + +```typescript +import { defineConfig } from "vite"; +import { effexPlatform } from "@effex/vite-plugin"; + +export default defineConfig({ + plugins: [ + effexPlatform({ entry: "src/vite-entry.ts" }), + ], +}); +``` + +### Scripts + +| Command | What it does | +|---------|-------------| +| `pnpm dev` | Vite dev server with SSR | +| `pnpm build` | Build client and server bundles | +| `pnpm start` | Run the production server | + +--- + +## SSG (Static Site Generation) + +```bash +pnpm create effex my-app --ssg +``` + +Pages are pre-rendered to HTML at build time. No server at runtime — just static files you can deploy anywhere. Use this for docs sites, blogs, marketing pages, or anything where the content is known ahead of time. + +### What you get + +``` +my-app/ +├── src/ +│ ├── App.ts # Root component +│ ├── routes.ts # Route definitions with static paths +│ ├── entry.ts # Build-time entry point +│ └── client.ts # Client hydration entry +├── public/ +│ └── styles.css +├── vite.config.ts +└── tsconfig.json +``` + +### Static routes + +SSG routes use `Route.static()` to declare which paths to generate and how to load data for each: + +```typescript +import { Effect } from "effect"; +import { Route, Router } from "@effex/router"; +import { $ } from "@effex/dom"; + +const DocsRoute = Route.make("/docs/:slug").pipe( + Route.static({ + // Which paths to generate + paths: () => + Effect.succeed([ + { slug: "getting-started" }, + { slug: "routing" }, + ]), + + // Load data for each path (runs at build time) + load: ({ params }) => + Effect.succeed({ + title: params.slug, + content: `Content for ${params.slug}`, + }), + + // Render with loaded data + render: (data) => + $.article( + {}, + $.of(data.content), + ), + }), +); +``` + +### Build-time entry + +`src/entry.ts` exports everything the SSG builder needs: + +```typescript +import { App } from "./App.js"; +import { router } from "./routes.js"; + +export { router, App }; + +export const document = { + title: "My Site", + scripts: ["/client.js"], + styles: ["/styles.css"], +}; +``` + +### Client hydration + +Like SSR, the client hydrates after the static HTML loads. But since there's no server at runtime, `Platform.makeClientLayer` isn't needed — route data is embedded in the HTML: + +```typescript +import { hydrate } from "@effex/dom"; +import { App } from "./App.js"; + +hydrate(App(), document.getElementById("root")!); +``` + +### Scripts + +| Command | What it does | +|---------|-------------| +| `pnpm dev` | Vite dev server with SSR rendering | +| `pnpm build` | Generate static HTML + client bundle | +| `pnpm preview` | Preview the static site | + +--- + +## Which template should I use? + +| | SPA | SSR | SSG | +|---|---|---|---| +| **SEO** | No | Yes | Yes | +| **Initial load speed** | Slower (JS must execute) | Fast (HTML from server) | Fastest (pre-built HTML) | +| **Dynamic data** | Client-side fetching | Server loaders | Build-time only | +| **Hosting** | Any static host | Node.js server | Any static host | +| **Best for** | Dashboards, internal tools | Apps with auth, real-time data | Docs, blogs, marketing | + +You can always change later — the component model is the same across all three. The main difference is the entry point and how data gets loaded. diff --git a/apps/docs/index.html b/apps/docs/index.html index b778acca..c2614184 100644 --- a/apps/docs/index.html +++ b/apps/docs/index.html @@ -1,5 +1,5 @@ -<!DOCTYPE html> -<html lang="en"> +<!doctype html> +<html lang="en" data-theme="dark"> <head> <meta charset="UTF-8" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" /> diff --git a/apps/docs/package.json b/apps/docs/package.json index 6975749a..7db7be3b 100644 --- a/apps/docs/package.json +++ b/apps/docs/package.json @@ -13,8 +13,14 @@ "@effex/dom": "workspace:*", "@effex/platform": "workspace:*", "@effex/router": "workspace:*", + "@shikijs/markdown-it": "^4.0.2", + "@tailwindcss/vite": "^4.2.1", + "daisyui": "^5.5.19", "effect": "3.19.13", - "markdown-it": "^14.1.0" + "lucide-static": "^0.576.0", + "markdown-it": "^14.1.0", + "shiki": "^4.0.2", + "tailwindcss": "^4.2.1" }, "devDependencies": { "@effex/vite-plugin": "workspace:*", diff --git a/apps/docs/src/assets/GitHub_Invertocat_Black.svg b/apps/docs/src/assets/GitHub_Invertocat_Black.svg new file mode 100644 index 00000000..797bec7c --- /dev/null +++ b/apps/docs/src/assets/GitHub_Invertocat_Black.svg @@ -0,0 +1,10 @@ +<svg width="98" height="96" viewBox="0 0 98 96" fill="none" xmlns="http://www.w3.org/2000/svg"> +<g clip-path="url(#clip0_730_27126)"> +<path d="M41.4395 69.3848C28.8066 67.8535 19.9062 58.7617 19.9062 46.9902C19.9062 42.2051 21.6289 37.0371 24.5 33.5918C23.2559 30.4336 23.4473 23.7344 24.8828 20.959C28.7109 20.4805 33.8789 22.4902 36.9414 25.2656C40.5781 24.1172 44.4062 23.543 49.0957 23.543C53.7852 23.543 57.6133 24.1172 61.0586 25.1699C64.0254 22.4902 69.2891 20.4805 73.1172 20.959C74.457 23.543 74.6484 30.2422 73.4043 33.4961C76.4668 37.1328 78.0937 42.0137 78.0937 46.9902C78.0937 58.7617 69.1934 67.6621 56.3691 69.2891C59.623 71.3945 61.8242 75.9883 61.8242 81.252L61.8242 91.2051C61.8242 94.0762 64.2168 95.7031 67.0879 94.5547C84.4102 87.9512 98 70.6289 98 49.1914C98 22.1074 75.9883 6.69539e-07 48.9043 4.309e-07C21.8203 1.92261e-07 -1.9479e-07 22.1074 -4.3343e-07 49.1914C-6.20631e-07 70.4375 13.4941 88.0469 31.6777 94.6504C34.2617 95.6074 36.75 93.8848 36.75 91.3008L36.75 83.6445C35.4102 84.2188 33.6875 84.6016 32.1562 84.6016C25.8398 84.6016 22.1074 81.1563 19.4277 74.7441C18.375 72.1602 17.2266 70.6289 15.0254 70.3418C13.877 70.2461 13.4941 69.7676 13.4941 69.1934C13.4941 68.0449 15.4082 67.1836 17.3223 67.1836C20.0977 67.1836 22.4902 68.9063 24.9785 72.4473C26.8926 75.2227 28.9023 76.4668 31.2949 76.4668C33.6875 76.4668 35.2187 75.6055 37.4199 73.4043C39.0469 71.7773 40.291 70.3418 41.4395 69.3848Z" /> +</g> +<defs> +<clipPath id="clip0_730_27126"> +<r0ect width="98" height="96" /> +</clipPath> +</defs> +</svg> diff --git a/apps/docs/src/assets/effex-logo-dark.svg b/apps/docs/src/assets/effex-logo-dark.svg new file mode 100644 index 00000000..d35d897a --- /dev/null +++ b/apps/docs/src/assets/effex-logo-dark.svg @@ -0,0 +1,10 @@ +<svg width="373" height="128" viewBox="0 0 373 128" fill="none" xmlns="http://www.w3.org/2000/svg"> +<path d="M141.8 99V31.8H181.16V43.896H155.24V58.968H180.68V70.68H155.24V86.904H181.352V99H141.8Z" fill="white"/> +<path d="M193.519 99V60.12H186.511V49.08H193.519V45.72C193.519 40.664 195.023 36.6 198.031 33.528C201.039 30.456 205.071 28.92 210.127 28.92H215.503V39.96H212.143C208.367 39.96 206.479 41.944 206.479 45.912V49.08H216.655V60.12H206.479V99H193.519Z" fill="white"/> +<path d="M225.582 99V60.12H218.574V49.08H225.582V45.72C225.582 40.664 227.086 36.6 230.094 33.528C233.102 30.456 237.134 28.92 242.19 28.92H247.566V39.96H244.206C240.43 39.96 238.542 41.944 238.542 45.912V49.08H248.718V60.12H238.542V99H225.582Z" fill="white"/> +<path d="M274.525 100.248C269.405 100.248 264.893 99.064 260.989 96.696C257.085 94.328 254.045 91.192 251.869 87.288C249.757 83.32 248.701 78.936 248.701 74.136C248.701 69.336 249.821 64.952 252.061 60.984C254.301 56.952 257.341 53.752 261.181 51.384C265.085 49.016 269.533 47.832 274.525 47.832C279.517 47.832 283.901 49.016 287.677 51.384C291.517 53.752 294.493 56.952 296.605 60.984C298.781 64.952 299.869 69.336 299.869 74.136C299.869 74.84 299.837 75.576 299.773 76.344C299.709 77.112 299.613 77.912 299.485 78.744H262.141C262.845 81.688 264.253 84.088 266.365 85.944C268.541 87.8 271.261 88.728 274.525 88.728C277.341 88.728 279.773 88.088 281.821 86.808C283.933 85.528 285.565 83.928 286.717 82.008L296.797 89.592C294.813 92.728 291.837 95.288 287.869 97.272C283.901 99.256 279.453 100.248 274.525 100.248ZM274.333 58.968C271.261 58.968 268.637 59.896 266.461 61.752C264.285 63.608 262.845 66.04 262.141 69.048H286.813C286.109 66.296 284.637 63.928 282.397 61.944C280.221 59.96 277.533 58.968 274.333 58.968Z" fill="white"/> +<path d="M295.978 99L314.698 74.04L296.938 49.08H312.394L322.57 64.92L333.13 49.08H348.298L330.538 74.232L349.258 99H333.514L322.665 83.064L311.434 99H295.978Z" fill="white"/> +<path d="M83.6714 17.2343C86.7746 14.9287 91.8064 14.9287 94.9097 17.2343L112.028 29.953C115.131 32.2586 115.131 35.9962 112.028 38.3017L93.7417 51.8866L112.028 65.4716C115.131 67.7772 115.131 71.5156 112.028 73.8212L94.9097 86.539C91.8064 88.8445 86.7746 88.8445 83.6714 86.539L65.3862 72.953L47.1011 86.539C43.9978 88.8443 38.9669 88.8443 35.8638 86.539L18.7456 73.8212C15.6423 71.5156 15.6423 67.7772 18.7456 65.4716L37.0298 51.8866L18.7456 38.3017C15.6423 35.9961 15.6423 32.2576 18.7456 29.952L35.8638 17.2343C38.9669 14.929 43.9979 14.9291 47.1011 17.2343L65.3862 30.8192L83.6714 17.2343Z" fill="white"/> +<path d="M89.459 33.6796C90.8009 33.6796 92.0334 34.0642 92.8877 34.6972L111.475 48.4745C112.302 49.0878 112.5 49.7062 112.5 50.1103C112.5 50.4892 112.326 51.0568 111.622 51.6318L111.475 51.746L92.4326 65.8593L89.7227 67.8671L92.4326 69.8759L111.475 83.9902C112.302 84.6033 112.5 85.2218 112.5 85.6259C112.5 86.0048 112.326 86.5725 111.622 87.1474L111.475 87.2626L92.8877 101.038C92.0334 101.671 90.801 102.057 89.459 102.057C88.117 102.057 86.8845 101.671 86.0303 101.038L66.9883 86.9247L65.5 85.8212L64.0117 86.9247L44.9697 101.038C44.1155 101.671 42.883 102.056 41.541 102.056C40.199 102.056 38.9666 101.671 38.1123 101.038L19.5254 87.2626C18.698 86.6494 18.5 86.0301 18.5 85.6259C18.5001 85.247 18.6745 84.68 19.3779 84.1054L19.5254 83.9902L38.5664 69.8759L41.2764 67.8671L38.5664 65.8593L19.5254 51.746C18.6981 51.1328 18.5 50.5144 18.5 50.1103C18.5001 49.7061 18.6982 49.0877 19.5254 48.4745L38.1123 34.6972C38.9665 34.0642 40.1991 33.6796 41.541 33.6796C42.883 33.6797 44.1155 34.0651 44.9697 34.6982V34.6972L64.0117 48.8114L65.5 49.914L66.9883 48.8114L86.0303 34.6972C86.8845 34.0641 88.117 33.6796 89.459 33.6796Z" stroke="white" stroke-width="5"/> +<path d="M112.671 101.389L94.1984 114.74C91.4995 116.691 87.1236 116.691 84.4246 114.74L65.5 101.062L46.5754 114.74C43.8764 116.691 39.5005 116.691 36.8016 114.74L18.3294 101.389" stroke="white" stroke-width="5" stroke-linecap="round"/> +</svg> diff --git a/apps/docs/src/assets/github.svg b/apps/docs/src/assets/github.svg new file mode 100755 index 00000000..f8c541a5 --- /dev/null +++ b/apps/docs/src/assets/github.svg @@ -0,0 +1,10 @@ +<svg width="98" height="96" viewBox="0 0 98 96" fill="none" xmlns="http://www.w3.org/2000/svg"> +<g clip-path="url(#clip0_730_27126)"> +<path d="M41.4395 69.3848C28.8066 67.8535 19.9062 58.7617 19.9062 46.9902C19.9062 42.2051 21.6289 37.0371 24.5 33.5918C23.2559 30.4336 23.4473 23.7344 24.8828 20.959C28.7109 20.4805 33.8789 22.4902 36.9414 25.2656C40.5781 24.1172 44.4062 23.543 49.0957 23.543C53.7852 23.543 57.6133 24.1172 61.0586 25.1699C64.0254 22.4902 69.2891 20.4805 73.1172 20.959C74.457 23.543 74.6484 30.2422 73.4043 33.4961C76.4668 37.1328 78.0937 42.0137 78.0937 46.9902C78.0937 58.7617 69.1934 67.6621 56.3691 69.2891C59.623 71.3945 61.8242 75.9883 61.8242 81.252L61.8242 91.2051C61.8242 94.0762 64.2168 95.7031 67.0879 94.5547C84.4102 87.9512 98 70.6289 98 49.1914C98 22.1074 75.9883 6.69539e-07 48.9043 4.309e-07C21.8203 1.92261e-07 -1.9479e-07 22.1074 -4.3343e-07 49.1914C-6.20631e-07 70.4375 13.4941 88.0469 31.6777 94.6504C34.2617 95.6074 36.75 93.8848 36.75 91.3008L36.75 83.6445C35.4102 84.2188 33.6875 84.6016 32.1562 84.6016C25.8398 84.6016 22.1074 81.1563 19.4277 74.7441C18.375 72.1602 17.2266 70.6289 15.0254 70.3418C13.877 70.2461 13.4941 69.7676 13.4941 69.1934C13.4941 68.0449 15.4082 67.1836 17.3223 67.1836C20.0977 67.1836 22.4902 68.9063 24.9785 72.4473C26.8926 75.2227 28.9023 76.4668 31.2949 76.4668C33.6875 76.4668 35.2187 75.6055 37.4199 73.4043C39.0469 71.7773 40.291 70.3418 41.4395 69.3848Z" /> +</g> +<defs> +<clipPath id="clip0_730_27126"> +<rect width="98" height="96" /> +</clipPath> +</defs> +</svg> diff --git a/apps/docs/src/client.ts b/apps/docs/src/client.ts index 92249957..f9b41091 100644 --- a/apps/docs/src/client.ts +++ b/apps/docs/src/client.ts @@ -1,9 +1,14 @@ -import type { Element } from "@effex/dom"; +import { Effect } from "effect"; + import { hydrate } from "@effex/dom/hydrate"; +import { Navigation } from "@effex/router"; import { DocLayout } from "./layout.js"; +import { router } from "./routes.js"; + +const navLayer = Navigation.makeLayer(router); hydrate( - DocLayout() as unknown as Element.Element<HTMLElement>, + Effect.provide(DocLayout(), navLayer), document.getElementById("root")!, ); diff --git a/apps/docs/src/components/DocToc.ts b/apps/docs/src/components/DocToc.ts new file mode 100644 index 00000000..f7ca659f --- /dev/null +++ b/apps/docs/src/components/DocToc.ts @@ -0,0 +1,38 @@ +import { $, collect, Element } from "@effex/dom"; + +import { TocEntry } from "../content"; + +const TocItem = ( + entry: TocEntry, +): Element.Element<HTMLLIElement, never, never> => + $.li( + {}, + collect( + $.a( + { + href: `#${entry.id}`, + class: + "text-xs text-base-content/60 hover:text-base-content transition-colors", + }, + $.of(entry.title), + ), + entry.children.length > 0 + ? $.ul( + { class: "pl-4 border-l border-base-content/40 py-0 my-2" }, + collect(...entry.children.map(TocItem)), + ) + : $.div({}, $.of("")), + ), + ); + +export const DocToc = (toc: TocEntry[]) => + $.aside( + { class: "hidden w-56 lg:block" }, + collect( + $.h2( + { class: "font-bold text-lg mb-4 text-base-content/80" }, + $.of("On this page"), + ), + $.ul({ class: "flex flex-col gap-2" }, collect(...toc.map(TocItem))), + ), + ); diff --git a/apps/docs/src/components/Sidebar.ts b/apps/docs/src/components/Sidebar.ts new file mode 100644 index 00000000..ada5e4b6 --- /dev/null +++ b/apps/docs/src/components/Sidebar.ts @@ -0,0 +1,88 @@ +import { NotebookText } from "lucide-static"; + +import { $, collect } from "@effex/dom"; +import { Link } from "@effex/router"; + +import logoSvg from "../assets/effex-logo-dark.svg?raw"; +import type { DocSection } from "../content.js"; + +export const Sidebar = (props: { readonly sections: readonly DocSection[] }) => + $.div( + { class: "drawer-side" }, + collect( + $.label({ + for: "nav-drawer", + class: "drawer-overlay", + }), + $.div( + { class: "pt-6 bg-base-200 h-screen flex flex-col" }, + collect( + $.div( + { class: "pb-6 px-4 border-b border-neutral-500/50" }, + Link( + { href: "/", class: "flex gap-2 items-center group" }, + collect( + $.div({ + class: + "[&_svg]:h-8 [&_svg]:w-auto group-hover:-translate-y-1 transition-transform", + innerHTML: logoSvg, + }), + $.div( + { + class: [ + "uppercase tracking-widest px-2 py-1 rounded bg-primary -rotate-3 shadow", + "group-hover:rotate-0 group-hover:shadow-lg transition-transform", + ], + }, + $.of("Docs"), + ), + ), + ), + ), + $.nav( + { class: "flex-1 overflow-y-auto p-4" }, + collect( + ...props.sections.map((section) => + $.div( + { class: "mb-5" }, + collect( + $.h3( + { + class: + "text-xs font-semibold uppercase tracking-wide text-base-content/75 mb-1.5", + }, + $.of(section.name), + ), + $.ul( + { class: "menu menu-sm p-0" }, + collect( + ...section.pages.map((page) => + $.li( + {}, + Link( + { + href: `/docs/${page.slug}`, + class: + "text-base-content/60 hover:text-primary", + }, + collect( + $.i({ + class: "[&_svg]:w-4 [&_svg]:h-4", + innerHTML: NotebookText, + }), + $.span({}, $.of(page.title)), + ), + ), + ), + ), + ), + ), + ), + ), + ), + ), + ), + ), + ), + ), + ); diff --git a/apps/docs/src/components/SidebarLayout.ts b/apps/docs/src/components/SidebarLayout.ts new file mode 100644 index 00000000..7301c01c --- /dev/null +++ b/apps/docs/src/components/SidebarLayout.ts @@ -0,0 +1,32 @@ +import { Effect } from "effect"; + +import { $, collect, Element } from "@effex/dom"; + +import type { DocSection } from "../content.js"; +import { Sidebar } from "./Sidebar.js"; + +export const SidebarLayout = <E, R>( + props: { readonly sections: readonly DocSection[] }, + child: Element.Child<E, R>, +) => + Effect.gen(function* () { + const layout = yield* $.div( + { class: "drawer lg:drawer-open" }, + collect( + $.input({ id: "nav-drawer", type: "checkbox", class: "drawer-toggle" }), + $.main( + { class: "drawer-content p-4" }, + collect( + $.label( + { for: "nav-drawer", class: "btn drawer-button lg:hidden" }, + $.of("open"), + ), + $.div({ class: "px-8 pb-8" }, child), + ), + ), + + Sidebar({ sections: props.sections }), + ), + ); + return layout; + }); diff --git a/apps/docs/src/content.server.ts b/apps/docs/src/content.server.ts new file mode 100644 index 00000000..f9c60547 --- /dev/null +++ b/apps/docs/src/content.server.ts @@ -0,0 +1,212 @@ +/** + * Server-only content loading utilities. + * + * Reads markdown files from the content/ directory, + * parses frontmatter, and converts to HTML using markdown-it + Shiki. + */ + +import * as fs from "node:fs"; +import * as path from "node:path"; + +import shikiPlugin from "@shikijs/markdown-it"; +import { Effect } from "effect"; +import MarkdownIt from "markdown-it"; + +import type { DocPage, TocEntry } from "./content.js"; + +const slugify = (text: string): string => + text + .toLowerCase() + .replace(/<[^>]*>/g, "") + .replace(/[^\w\s-]/g, "") + .replace(/\s+/g, "-") + .replace(/-+/g, "-") + .trim(); + +const headingAnchorPlugin = (md: MarkdownIt): void => { + md.core.ruler.push("heading_anchors", (state) => { + for (let i = 0; i < state.tokens.length; i++) { + const token = state.tokens[i]; + if (token.type === "heading_open") { + const inlineToken = state.tokens[i + 1]; + if (inlineToken?.type === "inline" && inlineToken.content) { + const id = slugify(inlineToken.content); + token.attrSet("id", id); + } + } + } + }); +}; + +const mdPromise = (async () => { + const md = new MarkdownIt({ + html: true, + linkify: true, + typographer: true, + }); + + md.use( + await shikiPlugin({ + theme: "one-dark-pro", + }), + ); + + md.use(headingAnchorPlugin); + + return md; +})(); + +/** + * Render a fenced code block with Shiki syntax highlighting. + */ +export const renderCode = (code: string, lang: string): Effect.Effect<string> => + Effect.promise(async () => { + const md = await mdPromise; + return md.render(`\`\`\`${lang}\n${code}\n\`\`\``); + }); + +/** + * Extract a nested table of contents from rendered HTML. + */ +export const extractToc = (html: string): TocEntry[] => { + const headingRe = /<h([2-4])\s[^>]*id="([^"]*)"[^>]*>([\s\S]*?)<\/h\1>/g; + const flat: { level: number; id: string; title: string }[] = []; + + let match; + while ((match = headingRe.exec(html)) !== null) { + const title = match[3].replace(/<[^>]*>/g, "").trim(); + flat.push({ level: parseInt(match[1], 10), id: match[2], title }); + } + + const root: TocEntry[] = []; + const stack: { level: number; children: TocEntry[] }[] = [ + { level: 1, children: root }, + ]; + + for (const { level, id, title } of flat) { + const entry: TocEntry = { id, title, level, children: [] }; + + // Pop stack until we find a parent with a lower level + while (stack.length > 1 && stack[stack.length - 1].level >= level) { + stack.pop(); + } + + stack[stack.length - 1].children.push(entry); + stack.push({ level, children: entry.children }); + } + + return root; +}; + +// ─── Frontmatter parsing ───────────────────────────────────────────────────── + +const parseFrontmatter = ( + content: string, +): { meta: Record<string, string>; body: string } => { + const match = content.match(/^---\n([\s\S]*?)\n---\n([\s\S]*)$/); + if (!match) return { meta: {}, body: content }; + + const meta: Record<string, string> = {}; + for (const line of match[1].split("\n")) { + const colonIdx = line.indexOf(":"); + if (colonIdx === -1) continue; + const key = line.slice(0, colonIdx).trim(); + const value = line + .slice(colonIdx + 1) + .trim() + .replace(/^["']|["']$/g, ""); + meta[key] = value; + } + + return { meta, body: match[2] }; +}; + +// ─── Content directory discovery ───────────────────────────────────────────── + +const CONTENT_DIR = path.resolve( + import.meta.dirname ?? path.dirname(new URL(import.meta.url).pathname), + "..", + "content", +); + +/** + * Load a single markdown file and return a DocPage. + */ +export const loadPage = ( + section: string, + filename: string, +): Effect.Effect<DocPage> => + Effect.promise(async () => { + const md = await mdPromise; + const filePath = section + ? path.join(CONTENT_DIR, section, filename) + : path.join(CONTENT_DIR, filename); + const raw = fs.readFileSync(filePath, "utf-8"); + const { meta, body } = parseFrontmatter(raw); + const html = md.render(body); + + const slug = filename.replace(/\.md$/, ""); + + return { + slug: section ? `${section}/${slug}` : slug, + title: meta.title ?? slug, + description: meta.description ?? "", + order: parseInt(meta.order ?? "0", 10), + section, + html, + }; + }); + +/** + * Discover all doc pages and their sections. + */ +export const discoverPages = (): Effect.Effect<DocPage[]> => + Effect.promise(async () => { + const md = await mdPromise; + const pages: DocPage[] = []; + + const entries = fs.readdirSync(CONTENT_DIR, { withFileTypes: true }); + + for (const entry of entries) { + if (entry.isFile() && entry.name.endsWith(".md")) { + // Top-level page + const raw = fs.readFileSync( + path.join(CONTENT_DIR, entry.name), + "utf-8", + ); + const { meta, body } = parseFrontmatter(raw); + const slug = entry.name.replace(/\.md$/, ""); + pages.push({ + slug, + title: meta.title ?? slug, + description: meta.description ?? "", + order: parseInt(meta.order ?? "0", 10), + section: "", + html: md.render(body), + }); + } else if (entry.isDirectory()) { + // Section directory + const sectionDir = path.join(CONTENT_DIR, entry.name); + const files = fs + .readdirSync(sectionDir) + .filter((f) => f.endsWith(".md")) + .sort(); + + for (const file of files) { + const raw = fs.readFileSync(path.join(sectionDir, file), "utf-8"); + const { meta, body } = parseFrontmatter(raw); + const slug = file.replace(/\.md$/, ""); + pages.push({ + slug: `${entry.name}/${slug}`, + title: meta.title ?? slug, + description: meta.description ?? "", + order: parseInt(meta.order ?? "0", 10), + section: entry.name, + html: md.render(body), + }); + } + } + } + + return pages; + }); diff --git a/apps/docs/src/content.ts b/apps/docs/src/content.ts index 3b395a76..28a5308a 100644 --- a/apps/docs/src/content.ts +++ b/apps/docs/src/content.ts @@ -1,22 +1,7 @@ /** - * Content loading utilities for the docs site. - * - * At build time (SSG), these read markdown files from the content/ directory, - * parse frontmatter, and convert to HTML using markdown-it. + * Client-safe content types and utilities. */ -import * as fs from "node:fs"; -import * as path from "node:path"; - -import { Effect } from "effect"; -import MarkdownIt from "markdown-it"; - -const md = new MarkdownIt({ - html: true, - linkify: true, - typographer: true, -}); - // ─── Types ─────────────────────────────────────────────────────────────────── export interface DocPage { @@ -34,116 +19,19 @@ export interface DocSection { readonly pages: readonly DocPage[]; } -// ─── Frontmatter parsing ───────────────────────────────────────────────────── - -const parseFrontmatter = ( - content: string, -): { meta: Record<string, string>; body: string } => { - const match = content.match(/^---\n([\s\S]*?)\n---\n([\s\S]*)$/); - if (!match) return { meta: {}, body: content }; - - const meta: Record<string, string> = {}; - for (const line of match[1].split("\n")) { - const colonIdx = line.indexOf(":"); - if (colonIdx === -1) continue; - const key = line.slice(0, colonIdx).trim(); - const value = line - .slice(colonIdx + 1) - .trim() - .replace(/^["']|["']$/g, ""); - meta[key] = value; - } - - return { meta, body: match[2] }; -}; - -// ─── Content directory discovery ───────────────────────────────────────────── - -const CONTENT_DIR = path.resolve( - import.meta.dirname ?? path.dirname(new URL(import.meta.url).pathname), - "..", - "content", -); - -/** - * Load a single markdown file and return a DocPage. - */ -export const loadPage = ( - section: string, - filename: string, -): Effect.Effect<DocPage> => - Effect.sync(() => { - const filePath = section - ? path.join(CONTENT_DIR, section, filename) - : path.join(CONTENT_DIR, filename); - const raw = fs.readFileSync(filePath, "utf-8"); - const { meta, body } = parseFrontmatter(raw); - const html = md.render(body); - - const slug = filename.replace(/\.md$/, ""); - - return { - slug: section ? `${section}/${slug}` : slug, - title: meta.title ?? slug, - description: meta.description ?? "", - order: parseInt(meta.order ?? "0", 10), - section, - html, - }; - }); - -/** - * Discover all doc pages and their sections. - */ -export const discoverPages = (): Effect.Effect<DocPage[]> => - Effect.sync(() => { - const pages: DocPage[] = []; - - const entries = fs.readdirSync(CONTENT_DIR, { withFileTypes: true }); - - for (const entry of entries) { - if (entry.isFile() && entry.name.endsWith(".md")) { - // Top-level page - const raw = fs.readFileSync( - path.join(CONTENT_DIR, entry.name), - "utf-8", - ); - const { meta, body } = parseFrontmatter(raw); - const slug = entry.name.replace(/\.md$/, ""); - pages.push({ - slug, - title: meta.title ?? slug, - description: meta.description ?? "", - order: parseInt(meta.order ?? "0", 10), - section: "", - html: md.render(body), - }); - } else if (entry.isDirectory()) { - // Section directory - const sectionDir = path.join(CONTENT_DIR, entry.name); - const files = fs - .readdirSync(sectionDir) - .filter((f) => f.endsWith(".md")) - .sort(); +export interface PageLink { + readonly slug: string; + readonly title: string; +} - for (const file of files) { - const raw = fs.readFileSync(path.join(sectionDir, file), "utf-8"); - const { meta, body } = parseFrontmatter(raw); - const slug = file.replace(/\.md$/, ""); - pages.push({ - slug: `${entry.name}/${slug}`, - title: meta.title ?? slug, - description: meta.description ?? "", - order: parseInt(meta.order ?? "0", 10), - section: entry.name, - html: md.render(body), - }); - } - } - } +export interface TocEntry { + readonly id: string; + readonly title: string; + readonly level: number; + readonly children: TocEntry[]; +} - return pages; - }); +// ─── Pure utilities ────────────────────────────────────────────────────────── /** * Group pages into sections for navigation. @@ -181,12 +69,28 @@ export const getSections = (pages: DocPage[]): DocSection[] => { }); } - return sections; + return sections.sort((a, b) => a.slug.localeCompare(b.slug)); }; const sectionDisplayName = (slug: string): string => { return slug .split("-") + .filter((w) => !/\d+/.test(w)) .map((w) => w.charAt(0).toUpperCase() + w.slice(1)) .join(" "); }; + +/** + * Given a page slug and the full sections list, return the previous and next pages. + */ +export const getAdjacentPages = ( + slug: string, + sections: DocSection[], +): { prev: PageLink | null; next: PageLink | null } => { + const allPages = sections.flatMap((s) => s.pages); + const idx = allPages.findIndex((p) => p.slug === slug); + return { + prev: idx > 0 ? allPages[idx - 1] : null, + next: idx < allPages.length - 1 ? allPages[idx + 1] : null, + }; +}; diff --git a/apps/docs/src/entry.ts b/apps/docs/src/entry.ts index 00cb86a3..b0539272 100644 --- a/apps/docs/src/entry.ts +++ b/apps/docs/src/entry.ts @@ -7,7 +7,6 @@ import { HttpApp, HttpRouter } from "@effect/platform"; -import type { Element } from "@effex/dom"; import { Platform } from "@effex/platform"; import { DocLayout } from "./layout.js"; @@ -17,16 +16,17 @@ const documentOptions = { title: "Effex Docs", scripts: ["/src/client.ts"], styles: ["/src/styles.css"], + htmlAttrs: { lang: "en", "data-theme": "dark" }, }; // Used by buildStaticSite() at build time export { router }; -export const app = DocLayout as unknown as () => Element.Element; +export const app = DocLayout; export const document = documentOptions; // Used by the dev server during development const effexRoutes = Platform.toHttpRoutes(router, { - app: DocLayout as unknown as () => Element.Element, + app: DocLayout, document: documentOptions, }); diff --git a/apps/docs/src/layout.ts b/apps/docs/src/layout.ts index 026dc2ab..91b5e0b3 100644 --- a/apps/docs/src/layout.ts +++ b/apps/docs/src/layout.ts @@ -5,6 +5,6 @@ import { router } from "./routes.js"; export const DocLayout = () => $.div( - { class: "app" }, + { class: "min-h-screen bg-base-100 text-base-content" }, Outlet({ router }), ); diff --git a/apps/docs/src/pages/DocPage.ts b/apps/docs/src/pages/DocPage.ts new file mode 100644 index 00000000..60d3246a --- /dev/null +++ b/apps/docs/src/pages/DocPage.ts @@ -0,0 +1,100 @@ +import { Effect } from "effect"; + +import { $, collect } from "@effex/dom"; +import { Link } from "@effex/router"; + +import GithubIcon from "../assets/github.svg?raw"; +import { DocToc } from "../components/DocToc.js"; +import { SidebarLayout } from "../components/SidebarLayout.js"; +import type { + DocPage as DocPageData, + DocSection, + PageLink, + TocEntry, +} from "../content.js"; + +export const DocPage = (props: { + readonly page: DocPageData; + readonly sections: readonly DocSection[]; + readonly prev: PageLink | null; + readonly next: PageLink | null; + readonly toc: TocEntry[]; +}) => + Effect.gen(function* () { + const pagination = $.nav( + { + class: + "flex justify-between items-center mt-12 pt-6 border-t border-base-content/10 max-w-[40rem] mx-auto", + }, + collect( + props.prev + ? Link( + { + href: `/docs/${props.prev.slug}`, + class: + "flex flex-col items-start gap-1 text-sm hover:text-primary transition-colors", + }, + collect( + $.span( + { class: "text-base-content/50 text-xs" }, + $.of("Previous"), + ), + $.span({}, $.of(`← ${props.prev.title}`)), + ), + ) + : $.div({}, $.of("")), + props.next + ? Link( + { + href: `/docs/${props.next.slug}`, + class: + "flex flex-col items-end gap-1 text-sm hover:text-primary transition-colors", + }, + collect( + $.span({ class: "text-base-content/50 text-xs" }, $.of("Next")), + $.span({}, $.of(`${props.next.title} →`)), + ), + ) + : $.div({}, $.of("")), + ), + ); + + const page = yield* SidebarLayout( + { sections: props.sections }, + $.div( + { class: "flex flex-col gap-8" }, + collect( + $.div( + { class: "p-4 flex justify-end" }, + collect( + $.a({ + href: "https://github.com/jonlaing/effex", + class: + "[&_svg]:fill-base-content/50 [&_svg]:hover:fill-primary [&_svg]:transition-colors [&_svg]:w-8 [&_svg]:h-8", + target: "_blank", + rel: "noopener noreferrer", + innerHTML: GithubIcon, + }), + ), + ), + $.div( + { class: "flex gap-8" }, + collect( + $.div( + { class: "flex-1" }, + collect( + $.article({ + class: "prose max-w-[40rem] mx-auto", + innerHTML: props.page.html, + }), + pagination, + ), + ), + DocToc(props.toc), + ), + ), + ), + ), + ); + return page; + }); diff --git a/apps/docs/src/pages/HomePage.ts b/apps/docs/src/pages/HomePage.ts new file mode 100644 index 00000000..f761373e --- /dev/null +++ b/apps/docs/src/pages/HomePage.ts @@ -0,0 +1,442 @@ +import { Effect } from "effect"; + +import { $, collect } from "@effex/dom"; +import { Link } from "@effex/router"; + +import logoSvg from "../assets/effex-logo-dark.svg?raw"; + +// ─── Code examples ────────────────────────────────────────────────────────── + +export const counterExample = `import { Effect } from "effect"; +import { $, collect, Signal, mount, runApp } from "@effex/dom"; + +const Counter = () => + Effect.gen(function* () { + const count = yield* Signal.make(0); + + return yield* $.div( + { class: "flex items-center gap-4" }, + collect( + $.button( + { + class: "btn btn-primary", + onClick: () => count.update((n) => n - 1), + }, + $.of("-"), + ), + $.span({ class: "text-2xl tabular-nums" }, count), + $.button( + { + class: "btn btn-primary", + onClick: () => count.update((n) => n + 1), + }, + $.of("+"), + ), + ), + ); + }); + +// Run the app! +runApp(mount(Counter(), document.getElementById("root")!));`; + +export const signalsExample = `// Signals are references, not snapshots. +// No stale closures, no dependency arrays. +const name = yield* Signal.make("world"); + +// Use a signal directly as element content — +// the text node updates when name changes. +const greeting = yield* $.h1({}, name); + +// Derived values update automatically. +const upper = Readable.map(name, (n) => n.toUpperCase()); +const shout = yield* $.p({}, upper);`; + +export const errorsExample = `// This component can fail — the error type says so. +const UserProfile = (id: string): Element<HttpError, ApiClient> => + Effect.gen(function* () { + const api = yield* ApiClient; + const user = yield* api.getUser(id); + return yield* $.div({}, $.of(user.name)); + }); + +// TypeScript won't let you mount this without +// handling HttpError and providing ApiClient. +// Errors are visible in the types, not hidden at runtime.`; + +export const fullstackExample = `// Same component, three targets. + +// SPA — client-side only +runApp(mount(App(), root)); + +// SSR — server renders, client hydrates +// server: +const routes = Platform.toHttpRoutes(router, opts); +// client: +hydrate(App(), root); + +// SSG — pre-render at build time +Route.static({ + paths: () => discoverPages(), + load: ({ params }) => loadPage(params.slug), + render: (data) => DocPage(data), +});`; + +// ─── Helper to build a story section ──────────────────────────────────────── + +const storySection = ( + heading: string, + description: string, + codeHtml: string, + options?: { reverse?: boolean }, +) => + $.div( + { + class: `grid grid-cols-1 lg:grid-cols-2 gap-8 items-center ${options?.reverse ? "lg:[direction:rtl] lg:[&>*]:[direction:ltr]" : ""}`, + }, + collect( + $.div( + { class: "space-y-4" }, + collect( + $.h3({ class: "text-2xl font-bold" }, $.of(heading)), + $.p( + { class: "text-base-content/70 leading-relaxed" }, + $.of(description), + ), + ), + ), + $.div({ + class: + "rounded-xl shadow-lg overflow-hidden [&_pre]:!rounded-none [&_pre]:!m-0 [&_pre]:!p-6 [&_pre]:!text-xs", + innerHTML: codeHtml, + }), + ), + ); + +// ─── Package card ─────────────────────────────────────────────────────────── + +const packageCard = (name: string, description: string, pkg: string) => + $.a( + { + href: `https://github.com/jonlaing/effex/tree/main/packages/${pkg}`, + class: "card bg-base-300 shadow-sm hover:bg-neutral transition-colors", + target: "_blank", + }, + $.div( + { class: "card-body p-5" }, + collect( + $.h3( + { class: "font-mono text-sm text-primary font-semibold" }, + $.of(name), + ), + $.p({ class: "text-base-content/70 text-sm" }, $.of(description)), + ), + ), + ); + +// ─── Page ─────────────────────────────────────────────────────────────────── + +export const HomePage = (props: { + readonly codeExamples: { + counterHtml: string; + signalsHtml: string; + errorsHtml: string; + fullstackHtml: string; + }; +}) => + Effect.gen(function* () { + const { counterHtml, signalsHtml, errorsHtml, fullstackHtml } = + props.codeExamples; + + const page = yield* $.div( + {}, + collect( + // ── 1. Hero ─────────────────────────────────────────────────────── + $.div( + { class: "hero bg-base-300 py-16 overflow-hidden" }, + $.div( + { class: "hero-content text-center" }, + $.div( + {}, + collect( + $.h1({ + class: "text-4xl font-bold mb-2 animate-logo-in", + innerHTML: logoSvg, + }), + $.div( + { + class: + "text-lg text-base-content flex animate-subhead-fade-in mb-8", + }, + collect( + $.div({}, $.of("A reactive UI framework built on")), + $.div( + { + class: + "p-1 rounded bg-secondary text-secondary-content mx-1 font-bold -skew-y-2 shadow", + }, + $.of("Effect.ts"), + ), + $.div({}, $.of("primitives.")), + ), + ), + $.div( + { class: "animate-slow-fade-in space-y-10" }, + collect( + $.div( + { class: "flex gap-4 justify-center" }, + collect( + Link( + { + href: "/docs/quick-start", + class: "btn btn-primary", + }, + $.of("Quick Start Guide"), + ), + Link( + { + href: "/docs/introduction", + class: "btn btn-neutral", + }, + $.of("Documentation"), + ), + ), + ), + $.div( + { + class: + "inline-block bg-neutral rounded-lg px-6 py-3 font-mono text-sm", + }, + collect( + $.span({ class: "text-primary" }, $.of("$ ")), + $.span($.of("pnpm create effex my-app")), + ), + ), + ), + ), + ), + ), + ), + ), + + // ── 2. Code Example + Callouts ──────────────────────────────────── + $.div( + { + class: + "lg:max-w-6xl mx-auto py-16 px-4 space-y-16 flex flex-col-reverse md:flex-row gap-4", + }, + collect( + $.div( + { class: "flex flex-col gap-4 flex-1" }, + collect( + $.div( + { + class: "card shadow-sm bg-base-300 overflow-hidden flex-1", + }, + $.div( + { class: "card-body border-l-4 border-l-success" }, + collect( + $.h2({ class: "card-title" }, $.of("Fully Typesafe")), + $.p( + { class: "text-base-content/75" }, + $.of( + "Every element carries its error and dependency types. TypeScript catches unhandled failures and missing context at compile time — not in production.", + ), + ), + ), + ), + ), + $.div( + { + class: "card shadow-sm bg-base-300 overflow-hidden flex-1", + }, + $.div( + { class: "card-body border-l-4 border-l-info" }, + collect( + $.h2( + { class: "card-title" }, + $.of("Full Stack Reactivity"), + ), + $.p( + { class: "text-base-content/75" }, + $.of( + "The same signals, components, and router work across SPAs, server-rendered apps, and static sites. One model from prototype to production.", + ), + ), + ), + ), + ), + $.div( + { + class: "card shadow-sm bg-base-300 overflow-hidden flex-1", + }, + $.div( + { class: "card-body border-l-4 border-l-warning" }, + collect( + $.h2( + { class: "card-title" }, + collect( + $.span({}, $.of("Built on the power of ")), + $.a( + { + href: "https://effect.website", + class: "text-secondary", + }, + $.of("Effect.ts"), + ), + ), + ), + $.p( + { class: "text-base-content/75" }, + $.of( + "Structured concurrency, typed errors, dependency injection, and automatic resource cleanup — all built in. No extra libraries required.", + ), + ), + ), + ), + ), + ), + ), + $.div( + { class: "md:flex-1" }, + $.div({ + class: + "rounded-xl shadow-lg overflow-hidden [&_pre]:!rounded-none [&_pre]:!m-0 [&_pre]:!p-6 [&_pre]:!text-xs [&_pre]:flex [&_pre]:justify-center", + innerHTML: counterHtml, + }), + ), + ), + ), + + // ── 3. Story Sections ───────────────────────────────────────────── + $.div( + { class: "bg-base-200 py-16" }, + $.div( + { class: "lg:max-w-6xl mx-auto px-4 space-y-20" }, + collect( + $.div( + { class: "text-center pb-4" }, + $.h2({ class: "text-5xl font-bold" }, $.of("Why Effex?")), + ), + + storySection( + "Signals, not hooks", + "Signals are mutable references that track their own subscribers. Read a signal inside an element, and that element updates when the signal changes — automatically. No dependency arrays to maintain, no useCallback to remember, no stale closure bugs to chase down.", + signalsHtml, + ), + + storySection( + "Errors you can see", + "Every element in Effex has the type Element<E, R> — where E is the error channel and R is the required context. If a component can fail, TypeScript tells you before you ship. If it needs a service, the compiler asks for it. Runtime surprises become compile-time conversations.", + errorsHtml, + { reverse: true }, + ), + + storySection( + "One framework, every target", + "Write your components once. Run them client-side as an SPA, server-render with hydration, or pre-render as a static site. The same router, the same signals, the same component model — just a different entry point.", + fullstackHtml, + ), + ), + ), + ), + + // ── 4. Ecosystem ───────────────────────────────────────────────── + $.div( + { class: "lg:max-w-6xl mx-auto py-16 px-4" }, + collect( + $.div( + { class: "text-center mb-10" }, + collect( + $.h2( + { class: "text-3xl font-bold mb-2" }, + $.of("The full picture"), + ), + $.p( + { class: "text-base-content/70" }, + $.of( + "A complete set of packages that work together — or independently.", + ), + ), + ), + ), + $.div( + { class: "grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4" }, + collect( + packageCard( + "@effex/core", + "Reactive primitives — Signal, Readable, reactive collections, control flow, and transitions.", + "core", + ), + packageCard( + "@effex/dom", + "DOM rendering with the $ factory, animations, portals, virtual lists, and hydration.", + "dom", + ), + packageCard( + "@effex/router", + "Type-safe routing with schema-validated params, data loaders, and mutation handlers.", + "router", + ), + packageCard( + "@effex/form", + "Schema-first forms with per-field reactivity, validation, and nested structures.", + "form", + ), + packageCard( + "@effex/platform", + "Full-stack SSR integration with @effect/platform — server rendering, data serialization, and hydration.", + "platform", + ), + packageCard( + "@effex/vite-plugin", + "Vite plugin for SSR dev server, server-code stripping, and static site generation.", + "vite-plugin", + ), + ), + ), + ), + ), + + // ── 5. Final CTA ───────────────────────────────────────────────── + $.div( + { class: "bg-base-200 py-16" }, + $.div( + { class: "text-center space-y-6" }, + collect( + $.h2( + { class: "text-3xl font-bold" }, + $.of("Get started in seconds"), + ), + $.div( + { + class: + "inline-block bg-neutral rounded-lg px-6 py-3 font-mono text-sm", + }, + collect( + $.span({ class: "text-primary" }, $.of("$ ")), + $.span($.of("pnpm create effex my-app")), + ), + ), + $.div( + { class: "flex gap-4 justify-center" }, + collect( + Link( + { + href: "/docs/02-todo-app/00-introduction", + class: "btn btn-primary", + }, + $.of("Follow the Tutorial"), + ), + Link( + { href: "/docs/introduction", class: "btn btn-neutral" }, + $.of("Read the Docs"), + ), + ), + ), + ), + ), + ), + ), + ); + return page; + }); diff --git a/apps/docs/src/pages/NotFoundPage.ts b/apps/docs/src/pages/NotFoundPage.ts new file mode 100644 index 00000000..0f127db7 --- /dev/null +++ b/apps/docs/src/pages/NotFoundPage.ts @@ -0,0 +1,22 @@ +import { Effect } from "effect"; + +import { $, collect } from "@effex/dom"; +import { Link } from "@effex/router"; + +export const NotFoundPage = () => + Effect.gen(function* () { + const page = yield* $.div( + { class: "max-w-2xl mx-auto px-6 py-32 text-center" }, + collect( + $.h1( + { class: "text-3xl font-bold mb-4" }, + $.of("404 — Page Not Found"), + ), + $.p( + {}, + Link({ href: "/", class: "link link-primary" }, $.of("Back to Home")), + ), + ), + ); + return page; + }); diff --git a/apps/docs/src/routes.ts b/apps/docs/src/routes.ts index 763e86ec..8db9274f 100644 --- a/apps/docs/src/routes.ts +++ b/apps/docs/src/routes.ts @@ -1,9 +1,23 @@ import { Effect } from "effect"; -import { $, collect } from "@effex/dom"; -import { Link, Route, Router } from "@effex/router"; +import { Route, Router } from "@effex/router"; -import { discoverPages, getSections, loadPage } from "./content.js"; +import { getAdjacentPages, getSections } from "./content.js"; +import { + discoverPages, + extractToc, + loadPage, + renderCode, +} from "./content.server.js"; +import { DocPage } from "./pages/DocPage.js"; +import { + counterExample, + errorsExample, + fullstackExample, + HomePage, + signalsExample, +} from "./pages/HomePage.js"; +import { NotFoundPage } from "./pages/NotFoundPage.js"; // ─── Home page ─────────────────────────────────────────────────────────────── @@ -11,45 +25,24 @@ const HomeRoute = Route.make("/").pipe( Route.static({ load: () => Effect.gen(function* () { - const pages = yield* discoverPages(); - const sections = getSections(pages); - return { sections }; + const [counterHtml, signalsHtml, errorsHtml, fullstackHtml] = + yield* Effect.all([ + renderCode(counterExample, "typescript"), + renderCode(signalsExample, "typescript"), + renderCode(errorsExample, "typescript"), + renderCode(fullstackExample, "typescript"), + ]); + + return { + codeExamples: { + counterHtml, + signalsHtml, + errorsHtml, + fullstackHtml, + }, + }; }), - render: (data) => - $.div( - { class: "home" }, - collect( - $.h1({}, $.of("Effex Documentation")), - $.p( - { class: "lead" }, - $.of( - "A reactive UI framework built on Effect.ts primitives.", - ), - ), - ...data.sections.map((section) => - $.div( - { class: "section-group" }, - collect( - $.h2({}, $.of(section.name)), - $.ul( - {}, - collect( - ...section.pages.map((page) => - $.li( - {}, - Link( - { href: `/docs/${page.slug}` }, - $.of(page.title), - ), - ), - ), - ), - ), - ), - ), - ), - ), - ), + render: (data) => HomePage(data), }), ); @@ -60,7 +53,7 @@ const DocRoute = Route.make("/docs/*").pipe( paths: () => Effect.gen(function* () { const pages = yield* discoverPages(); - return pages.map((p) => ({ "*": p.slug } as Record<string, string>)); + return pages.map((p) => ({ "*": p.slug }) as Record<string, string>); }), load: ({ params }) => Effect.gen(function* () { @@ -70,63 +63,22 @@ const DocRoute = Route.make("/docs/*").pipe( const filename = parts[parts.length - 1] + ".md"; const page = yield* loadPage(section, filename); - // Load all pages for sidebar navigation const allPages = yield* discoverPages(); const sections = getSections(allPages); - return { page, sections }; + const { prev, next } = getAdjacentPages(slug, sections); + const toc = extractToc(page.html); + + return { page, sections, prev, next, toc }; }), render: (data) => - $.div( - { class: "doc-page" }, - collect( - $.aside( - { class: "sidebar" }, - collect( - $.div( - { class: "sidebar-header" }, - Link({ href: "/" }, $.of("Effex Docs")), - ), - $.nav( - {}, - collect( - ...data.sections.map((section) => - $.div( - { class: "nav-section" }, - collect( - $.h3({}, $.of(section.name)), - $.ul( - {}, - collect( - ...section.pages.map((page) => - $.li( - {}, - Link( - { href: `/docs/${page.slug}` }, - $.of(page.title), - ), - ), - ), - ), - ), - ), - ), - ), - ), - ), - ), - ), - $.main( - { class: "content" }, - collect( - $.article({ - class: "prose", - innerHTML: data.page.html, - }), - ), - ), - ), - ), + DocPage({ + page: data.page, + sections: data.sections, + prev: data.prev, + next: data.next, + toc: data.toc, + }), }), ); @@ -135,13 +87,5 @@ const DocRoute = Route.make("/docs/*").pipe( export const router = Router.empty.pipe( Router.concat(HomeRoute), Router.concat(DocRoute), - Router.fallback(() => - $.div( - { class: "not-found" }, - collect( - $.h1({}, $.of("404 — Page Not Found")), - $.p({}, Link({ href: "/" }, $.of("Back to Home"))), - ), - ), - ), + Router.fallback(() => NotFoundPage()), ); diff --git a/apps/docs/src/styles.css b/apps/docs/src/styles.css index d94f647e..4811c682 100644 --- a/apps/docs/src/styles.css +++ b/apps/docs/src/styles.css @@ -1,288 +1,195 @@ -/* ─── Reset & Base ──────────────────────────────────────────────────────── */ - -*, -*::before, -*::after { - box-sizing: border-box; - margin: 0; - padding: 0; -} - -html { - font-family: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, - sans-serif; - font-size: 16px; - line-height: 1.6; - color: #1a1a2e; - background: #fafafa; -} - -a { - color: #5b4fc4; - text-decoration: none; -} -a:hover { - text-decoration: underline; -} - -/* ─── App layout ────────────────────────────────────────────────────────── */ - -.app { - min-height: 100vh; -} - -/* ─── Home page ─────────────────────────────────────────────────────────── */ - -.home { - max-width: 720px; - margin: 0 auto; - padding: 60px 24px; -} - -.home h1 { - font-size: 2.5rem; - font-weight: 700; - margin-bottom: 8px; -} - -.home .lead { - font-size: 1.2rem; - color: #555; - margin-bottom: 48px; -} - -.section-group { - margin-bottom: 32px; -} - -.section-group h2 { - font-size: 1.1rem; - font-weight: 600; - text-transform: uppercase; - letter-spacing: 0.05em; - color: #888; - margin-bottom: 8px; - padding-bottom: 4px; - border-bottom: 1px solid #e5e5e5; -} - -.section-group ul { - list-style: none; -} - -.section-group li { - padding: 6px 0; -} - -.section-group li a { - font-size: 1rem; -} - -/* ─── Doc page layout ───────────────────────────────────────────────────── */ - -.doc-page { - display: flex; - min-height: 100vh; -} - -/* ─── Sidebar ───────────────────────────────────────────────────────────── */ - -.sidebar { - width: 280px; - flex-shrink: 0; - padding: 24px; - border-right: 1px solid #e5e5e5; - background: #fff; - position: sticky; - top: 0; - height: 100vh; - overflow-y: auto; -} - -.sidebar-header { - margin-bottom: 24px; -} - -.sidebar-header a { - font-size: 1.1rem; - font-weight: 700; - color: #1a1a2e; -} - -.nav-section { - margin-bottom: 20px; -} - -.nav-section h3 { - font-size: 0.75rem; - font-weight: 600; - text-transform: uppercase; - letter-spacing: 0.05em; - color: #888; - margin-bottom: 6px; -} - -.nav-section ul { - list-style: none; -} - -.nav-section li { - padding: 3px 0; -} - -.nav-section li a { - font-size: 0.9rem; - color: #555; -} - -.nav-section li a:hover { - color: #5b4fc4; -} - -.nav-section li a[data-active-exact="true"] { - color: #5b4fc4; - font-weight: 600; -} - -/* ─── Content ───────────────────────────────────────────────────────────── */ - -.content { - flex: 1; - min-width: 0; - padding: 48px 64px; - max-width: 800px; -} - -/* ─── Prose (markdown content) ──────────────────────────────────────────── */ +@import url("https://fonts.googleapis.com/css2?family=Noto+Serif:ital,wght@0,100..900;1,100..900&display=swap"); +@import url("https://fonts.googleapis.com/css2?family=Noto+Sans:ital,wght@0,100..900;1,100..900&display=swap"); +@import "tailwindcss"; + +@plugin "daisyui"; + +@plugin "daisyui/theme" { + name: "dark"; + default: true; + prefersdark: false; + color-scheme: "dark"; + --color-base-100: oklch(25.33% 0.016 252.42); + --color-base-200: oklch(23.26% 0.014 253.1); + --color-base-300: oklch(21.15% 0.012 254.09); + --color-base-content: oklch(97.807% 0.029 256.847); + --color-primary: oklch(58% 0.233 277.117); + --color-primary-content: oklch(96% 0.018 272.314); + --color-secondary: oklch(65% 0.241 354.308); + --color-secondary-content: oklch(94% 0.028 342.258); + --color-accent: oklch(77% 0.152 181.912); + --color-accent-content: oklch(38% 0.063 188.416); + --color-neutral: oklch(14% 0.005 285.823); + --color-neutral-content: oklch(92% 0.004 286.32); + --color-info: oklch(74% 0.16 232.661); + --color-info-content: oklch(29% 0.066 243.157); + --color-success: oklch(76% 0.177 163.223); + --color-success-content: oklch(37% 0.077 168.94); + --color-warning: oklch(82% 0.189 84.429); + --color-warning-content: oklch(41% 0.112 45.904); + --color-error: oklch(71% 0.194 13.428); + --color-error-content: oklch(27% 0.105 12.094); + --radius-selector: 0.5rem; + --radius-field: 0.25rem; + --radius-box: 0.5rem; + --size-selector: 0.25rem; + --size-field: 0.25rem; + --border: 1px; + --depth: 1; + --noise: 0; +} + +@theme { + --font-serif: "Noto Serif", serif; + --font-sans: "Noto Sans", sans-serif; +} + +/* ─── Sidebar active link ──────────────────────────────────────────────── */ + +a[data-active-exact="true"] { + @apply text-primary font-semibold; +} + +/* ─── Prose (markdown content) ─────────────────────────────────────────── */ .prose h1 { - font-size: 2rem; - font-weight: 700; - margin-bottom: 16px; - padding-bottom: 8px; - border-bottom: 1px solid #e5e5e5; + @apply text-5xl font-thin mb-4 pb-4 border-b border-base-content/50; } .prose h2 { - font-size: 1.5rem; - font-weight: 600; - margin-top: 40px; - margin-bottom: 12px; + @apply text-3xl mt-10 mb-3 text-primary-content; } .prose h3 { - font-size: 1.2rem; - font-weight: 600; - margin-top: 32px; - margin-bottom: 8px; + @apply text-xl mt-8 mb-4 text-primary-content; } .prose p { - margin-bottom: 16px; + @apply mb-4 leading-7 font-serif font-light text-base-content/75; } .prose ul, .prose ol { - margin-bottom: 16px; - padding-left: 24px; + @apply mb-4 pl-6; +} + +.prose ul { + @apply list-disc; +} + +.prose ol { + @apply list-decimal; } .prose li { - margin-bottom: 4px; + @apply mb-4 font-serif text-base-content/75; } .prose code { - font-family: "SF Mono", "Fira Code", "Fira Mono", Menlo, Consolas, monospace; - font-size: 0.9em; - background: #f0f0f5; - padding: 2px 6px; - border-radius: 4px; + @apply font-mono text-[0.9em] bg-base-300 px-1.5 py-0.5 rounded; } .prose pre { - margin-bottom: 20px; - padding: 16px; - background: #1e1e2e; - color: #cdd6f4; - border-radius: 8px; - overflow-x: auto; - font-size: 0.875rem; - line-height: 1.7; + @apply mb-5 rounded-lg overflow-x-auto text-sm leading-relaxed; } .prose pre code { - background: none; - padding: 0; - border-radius: 0; - color: inherit; + @apply bg-transparent p-0 rounded-none; +} + +.prose .shiki { + @apply p-4 rounded-lg overflow-x-auto; } .prose table { - width: 100%; - border-collapse: collapse; - margin-bottom: 20px; + @apply w-full border-collapse mb-5; } .prose th, .prose td { - text-align: left; - padding: 8px 12px; - border: 1px solid #e5e5e5; + @apply text-left px-3 py-2 border border-base-300; } .prose th { - background: #f5f5f5; - font-weight: 600; + @apply bg-base-200 font-semibold; } .prose blockquote { - border-left: 3px solid #5b4fc4; - padding: 8px 16px; - margin-bottom: 16px; - background: #f8f7ff; - color: #444; + @apply border-l-3 border-primary px-4 pt-2 pb-1 mb-4 bg-base-200 text-base-content rounded-r; +} + +.prose blockquote p { + @apply text-base-content; } .prose strong { - font-weight: 600; + @apply font-semibold; } .prose hr { - border: none; - border-top: 1px solid #e5e5e5; - margin: 32px 0; + @apply border-none border-t border-base-300 my-8; } -/* ─── 404 ───────────────────────────────────────────────────────────────── */ - -.not-found { - max-width: 720px; - margin: 0 auto; - padding: 120px 24px; - text-align: center; +.prose a { + @apply text-accent hover:underline; } -.not-found h1 { - font-size: 2rem; - margin-bottom: 16px; +@keyframes logo-in { + 0% { + opacity: 0; + transform: translateY(20rem) rotate(-30deg); + } + 50% { + opacity: 1; + transform: translateY(-40px) rotate(15deg); + } + 100% { + opacity: 1; + transform: translateY(0) rotate(0); + } } -/* ─── Responsive ────────────────────────────────────────────────────────── */ +.animate-logo-in { + animation: logo-in 1s ease-in-out; +} -@media (max-width: 768px) { - .doc-page { - flex-direction: column; +@keyframes subhead-fade-in { + 0% { + opacity: 0; + transform: translateY(-100px); } - - .sidebar { - width: 100%; - height: auto; - position: static; - border-right: none; - border-bottom: 1px solid #e5e5e5; + 50% { + opacity: 0; + transform: translateY(-100px); } + 75% { + opacity: 1; + transform: translateY(20px); + } + 100% { + opacity: 1; + transform: translateY(0); + } +} + +.animate-subhead-fade-in { + animation: subhead-fade-in 1.5s ease-in-out; + animation-fill-mode: forwards; +} - .content { - padding: 32px 24px; +@keyframes slow-fade-in { + 0% { + opacity: 0; } + 75% { + opacity: 0; + } + 100% { + opacity: 1; + } +} + +.animate-slow-fade-in { + animation: slow-fade-in 2s ease-in-out; + animation-fill-mode: forwards; } diff --git a/apps/docs/src/vite-env.d.ts b/apps/docs/src/vite-env.d.ts new file mode 100644 index 00000000..11f02fe2 --- /dev/null +++ b/apps/docs/src/vite-env.d.ts @@ -0,0 +1 @@ +/// <reference types="vite/client" /> diff --git a/apps/docs/vite.config.ts b/apps/docs/vite.config.ts index acf6a9a6..aba6d5e8 100644 --- a/apps/docs/vite.config.ts +++ b/apps/docs/vite.config.ts @@ -1,7 +1,11 @@ +import tailwindcss from "@tailwindcss/vite"; import { defineConfig } from "vite"; import { effexPlatform } from "@effex/vite-plugin"; export default defineConfig({ - plugins: [effexPlatform({ mode: "ssg", entry: "src/entry.ts" })], + plugins: [ + tailwindcss(), + effexPlatform({ mode: "ssg", entry: "src/entry.ts" }), + ], }); diff --git a/effex-logo-dark.svg b/effex-logo-dark.svg index d35d897a..934b696d 100644 --- a/effex-logo-dark.svg +++ b/effex-logo-dark.svg @@ -1,4 +1,4 @@ -<svg width="373" height="128" viewBox="0 0 373 128" fill="none" xmlns="http://www.w3.org/2000/svg"> +<svg viewBox="0 0 373 128" fill="none" xmlns="http://www.w3.org/2000/svg"> <path d="M141.8 99V31.8H181.16V43.896H155.24V58.968H180.68V70.68H155.24V86.904H181.352V99H141.8Z" fill="white"/> <path d="M193.519 99V60.12H186.511V49.08H193.519V45.72C193.519 40.664 195.023 36.6 198.031 33.528C201.039 30.456 205.071 28.92 210.127 28.92H215.503V39.96H212.143C208.367 39.96 206.479 41.944 206.479 45.912V49.08H216.655V60.12H206.479V99H193.519Z" fill="white"/> <path d="M225.582 99V60.12H218.574V49.08H225.582V45.72C225.582 40.664 227.086 36.6 230.094 33.528C233.102 30.456 237.134 28.92 242.19 28.92H247.566V39.96H244.206C240.43 39.96 238.542 41.944 238.542 45.912V49.08H248.718V60.12H238.542V99H225.582Z" fill="white"/> From 5023cffd13689ae388cd96a14e45b7da049e4a1c Mon Sep 17 00:00:00 2001 From: Jon Laing <jon@farsight-ai.com> Date: Sat, 28 Mar 2026 20:12:59 -0400 Subject: [PATCH 6/7] update changeset --- .changeset/green-ears-grab.md | 10 ++++++++++ 1 file changed, 10 insertions(+) create mode 100644 .changeset/green-ears-grab.md diff --git a/.changeset/green-ears-grab.md b/.changeset/green-ears-grab.md new file mode 100644 index 00000000..f317199d --- /dev/null +++ b/.changeset/green-ears-grab.md @@ -0,0 +1,10 @@ +--- +"create-effex": minor +"@effex/vite-plugin": minor +"@effex/platform": minor +"@effex/router": minor +"@effex/core": minor +"@effex/dom": minor +--- + +fixing type errors and reconfiguring router From 376c4b2067548d3512f5a1e47d2947f753094149 Mon Sep 17 00:00:00 2001 From: Jon Laing <jon@farsight-ai.com> Date: Sat, 28 Mar 2026 20:18:05 -0400 Subject: [PATCH 7/7] update lock file --- pnpm-lock.yaml | 13 ------------- 1 file changed, 13 deletions(-) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b9af7449..c3b55b2c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -65,9 +65,6 @@ importers: typedoc: specifier: ^0.28.15 version: 0.28.15(typescript@5.9.3) - typedoc-plugin-markdown: - specifier: ^4.9.0 - version: 4.9.0(typedoc@0.28.15(typescript@5.9.3)) typescript: specifier: ~5.9.3 version: 5.9.3 @@ -2237,12 +2234,6 @@ packages: resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==} engines: {node: '>= 0.8.0'} - typedoc-plugin-markdown@4.9.0: - resolution: {integrity: sha512-9Uu4WR9L7ZBgAl60N/h+jqmPxxvnC9nQAlnnO/OujtG2ubjnKTVUFY1XDhcMY+pCqlX3N2HsQM2QTYZIU9tJuw==} - engines: {node: '>= 18'} - peerDependencies: - typedoc: 0.28.x - typedoc@0.28.15: resolution: {integrity: sha512-mw2/2vTL7MlT+BVo43lOsufkkd2CJO4zeOSuWQQsiXoV2VuEn7f6IZp2jsUDPmBMABpgR0R5jlcJ2OGEFYmkyg==} engines: {node: '>= 18', pnpm: '>= 10'} @@ -4633,10 +4624,6 @@ snapshots: dependencies: prelude-ls: 1.2.1 - typedoc-plugin-markdown@4.9.0(typedoc@0.28.15(typescript@5.9.3)): - dependencies: - typedoc: 0.28.15(typescript@5.9.3) - typedoc@0.28.15(typescript@5.9.3): dependencies: '@gerrit0/mini-shiki': 3.20.0