diff --git a/.changeset/document-ssg-support.md b/.changeset/document-ssg-support.md new file mode 100644 index 0000000..f16e9c8 --- /dev/null +++ b/.changeset/document-ssg-support.md @@ -0,0 +1,6 @@ +--- +"@effex/platform": patch +"create-effex": patch +--- + +Surface SSG support in package documentation: add a Rendering Modes overview and a dedicated Static Site Generation section to the top-level README, give SSG equal billing with SSR in `@effex/platform`'s README (including a full Quick Start, `buildStaticSite` API reference, and `BuildStaticSiteOptions` shape), and document the SSG template, `--ssg` flag, project structure, and build commands in `create-effex`'s README. No code changes. diff --git a/README.md b/README.md index 05f8363..5a79c7c 100644 --- a/README.md +++ b/README.md @@ -64,23 +64,43 @@ Effex gives you access to Effect's entire ecosystem: - **Retry/timeout** — Built-in resilience patterns - **Structured concurrency** — Fork, join, and race without footguns +## Rendering Modes + +Effex supports three rendering modes. Pick based on what your app needs at runtime: + +| Mode | When to use | Output | Hosting | +|------|-------------|--------|---------| +| **SPA** | App needs no server logic; all data fetched client-side. Internal tools, dashboards. | Client bundle + `index.html` | Any static host | +| **SSR** | Per-request server logic (auth, dynamic data, mutations). Web apps with database access. | Long-running Node HTTP server + client bundle | Fly.io, Railway, VPS, Cloud Run | +| **SSG** | Content is known at build time. Portfolios, marketing sites, docs, blogs. | Pre-rendered HTML pages + client bundle for hydration | Any static host (Cloudflare Pages, Netlify, GitHub Pages) | + +All three produce interactive, hydrated pages — SSG output behaves identically to SSR output once the client bundle loads. The difference is purely *where rendering happens* (build time vs. request time vs. browser). + ## Quick Start ```bash -# Create a new project +# Create a new project — prompts you to pick SPA, SSR, or SSG pnpm create effex my-app cd my-app pnpm install pnpm dev ``` +Skip the prompt with a template flag: + +```bash +pnpm create effex my-app --spa # SPA +pnpm create effex my-app --ssr # SSR +pnpm create effex my-app --ssg # SSG +``` + Or install packages individually: ```bash # SPA (client-side only) pnpm add @effex/dom @effex/router effect -# Full-stack SSR +# Full-stack SSR / SSG pnpm add @effex/dom @effex/router @effex/platform @effect/platform effect ``` @@ -286,7 +306,7 @@ LoginForm.provide( Supports leaf fields, nested structs, arrays, and maps — all with Effect Schema validation. -## Full-Stack SSR +## Server-Side Rendering (SSR) `@effex/platform` bridges Effex with `@effect/platform`'s HTTP server for server-side rendering: @@ -324,6 +344,39 @@ Key features: - **Redirects** — Throw `RedirectError` from loaders for server-side redirects - **HttpApi composition** — Mount Effect's HttpApi alongside Effex pages on a single server +## Static Site Generation (SSG) + +The same `@effex/platform` package also supports building fully static sites. Routes opt in via `Route.static`, which declares the paths to generate and a build-time loader: + +```ts +// routes.ts +import { Route, Router } from "@effex/router"; + +const PostRoute = Route.make("/posts/:slug").pipe( + Route.params(Schema.Struct({ slug: Schema.String })), + Route.static({ + paths: () => Effect.succeed([{ slug: "hello" }, { slug: "world" }]), + load: ({ params }) => loadPostFromDisk(params.slug), + render: (post) => PostPage({ post }), + }), +); +``` + +Build to a `dist/` directory of static HTML at build time: + +```ts +// vite.config.ts +import { effexPlatform } from "@effex/vite-plugin"; + +export default defineConfig({ + plugins: [effexPlatform({ mode: "ssg", entry: "src/entry.ts" })], +}); +``` + +Output is fully hydratable — the generated HTML embeds loader data via `window.__EFFEX_DATA__`, and the client bundle picks up where the server left off. Animations, interactive components, signal-driven UI all work post-hydration. Deploy to any static host (Cloudflare Pages, Netlify, GitHub Pages, S3 + CloudFront). + +See [`@effex/platform`](./packages/platform) for the full `buildStaticSite` API. + ## Packages | Package | Description | @@ -334,7 +387,7 @@ Key features: | [`@effex/form`](./packages/form) | Schema-validated forms with reactive field state | | [`@effex/platform`](./packages/platform) | Server-side rendering, hydration, and data loading | | [`@effex/vite-plugin`](./packages/vite-plugin) | Vite plugin: SSR dev server + server-code stripping | -| [`create-effex`](./packages/create-effex) | CLI to scaffold new projects (SPA or SSR) | +| [`create-effex`](./packages/create-effex) | CLI to scaffold new projects (SPA, SSR, or SSG) | **Import conventions:** - `@effex/dom` re-exports everything from `@effex/core` — no need to install core separately diff --git a/packages/create-effex/README.md b/packages/create-effex/README.md index 0bcf407..6c9d759 100644 --- a/packages/create-effex/README.md +++ b/packages/create-effex/README.md @@ -50,6 +50,23 @@ Includes: - Production server with static file serving - Client hydration entry point +### SSG (Static Site Generation) + +A pre-rendered static site with client-side hydration. Good for portfolios, marketing sites, documentation, and blogs: + +```bash +pnpm create effex my-app --ssg +``` + +Includes: +- `@effex/dom` — DOM rendering and reactivity +- `@effex/router` — Routing (shared between build and client) +- `@effex/platform` — Static site generation via `buildStaticSite` +- `@effex/vite-plugin` configured in `ssg` mode — runs the static build after the client bundle +- Client hydration entry point + +Routes opt into static generation via `Route.static({ paths, load, render })`. The `paths` function returns all parameter sets to build; the `load` function runs at build time per path. Output is fully hydratable — animations and interactive components work the same as SSR once the client bundle loads. + ## Project Structure ### SPA Template @@ -85,6 +102,20 @@ my-app/ └── package.json ``` +### SSG Template + +``` +my-app/ +├── src/ +│ ├── App.ts # Root layout (shared build/client) +│ ├── client.ts # Client hydration entry +│ ├── entry.ts # Vite SSG entry (exports router + app + document) +│ └── routes.ts # Route definitions and router +├── vite.config.ts +├── tsconfig.json +└── package.json +``` + ## Development After creating your project: @@ -111,6 +142,15 @@ pnpm build # Build client + server bundles pnpm start # Run production server ``` +### SSG + +```bash +pnpm build # Build client bundle + generate static HTML for all Route.static routes +pnpm preview # Preview the static site locally +``` + +The build outputs static HTML pages plus a hashed client bundle to `dist/`. Deploy `dist/` to any static host. + ## CLI Options ``` @@ -119,6 +159,7 @@ create-effex [options] Options: --spa Use SPA template --ssr Use SSR template + --ssg Use SSG template --no-install Skip dependency installation --help Show help --version Show version diff --git a/packages/platform/README.md b/packages/platform/README.md index a038696..e44f7db 100644 --- a/packages/platform/README.md +++ b/packages/platform/README.md @@ -1,6 +1,9 @@ # @effex/platform -Server-side rendering and hydration utilities for Effex applications. Converts an Effex Router into `@effect/platform` HTTP handlers, handling SSR, data requests, and mutation endpoints. +Server-side rendering, static site generation, and hydration utilities for Effex applications. Supports two output modes: + +- **SSR** — Convert an Effex Router into `@effect/platform` HTTP handlers; renders pages per-request, handles data requests and mutations. +- **SSG** — Pre-render all routes at build time into static HTML files for deployment to any static host. ## Installation @@ -18,15 +21,29 @@ pnpm add @effex/dom @effex/router ## Overview -`@effex/platform` is a server-side package that bridges Effex's UI layer with `@effect/platform`'s HTTP server. It does not re-export dom or router — import those directly: +`@effex/platform` is a server-side package that bridges Effex's UI layer with `@effect/platform`'s HTTP server (for SSR) and a build-time static-site generator (for SSG). It does not re-export dom or router — import those directly: ```ts -import { $, collect, Readable } from "@effex/dom"; // UI primitives +import { $, collect, Readable } from "@effex/dom"; // UI primitives import { Route, Router, Outlet, Link } from "@effex/router"; // Routing -import { Platform, RedirectError } from "@effex/platform"; // SSR utilities +import { Platform, RedirectError } from "@effex/platform"; // SSR + SSG utilities ``` -## Quick Start +## SSR vs. SSG — which to use + +| | SSR | SSG | +|---|---|---| +| **Rendering happens** | Per request, on a long-running server | Once, at build time | +| **Route definition** | `Route.get(loader, render)` | `Route.static({ paths, load, render })` | +| **Deployment target** | Node host (Fly.io, Railway, VPS) | Any static host (Cloudflare Pages, Netlify, S3) | +| **Per-request data** | ✅ | ❌ (data fixed at build) | +| **Mutation handlers (`Route.post/put/delete`)** | ✅ | ❌ (no server at runtime) | +| **Operational cost** | Server uptime + compute | Free static hosting | +| **Hydration / interactivity** | ✅ identical to SSG | ✅ identical to SSR | + +Both modes produce fully hydratable output — the same client bundle picks up the SSR-rendered or pre-built HTML and brings the same interactive components to life. Choose based on whether you need per-request server logic, not based on whether you need interactivity. + +## SSR Quick Start ### Define Routes @@ -149,6 +166,127 @@ export const App = () => ); ``` +## SSG Quick Start + +### Define Static Routes + +Routes opt into static generation via `Route.static({ paths, load, render })`. The `paths` function returns all parameter sets to build; the `load` function runs at build time per parameter set: + +```ts +// routes.ts +import { Effect, Schema } from "effect"; +import { Route, Router } from "@effex/router"; + +import { HomePage } from "./pages/Home.js"; +import { PostPage } from "./pages/Post.js"; +import { NotFoundPage } from "./pages/NotFound.js"; + +const HomeRoute = Route.make("/").pipe( + Route.static({ + paths: () => Effect.succeed([{}]), // one path, no params + load: () => Effect.succeed({ heading: "Welcome" }), + render: (data) => HomePage({ data }), + }), +); + +const PostRoute = Route.make("/posts/:slug").pipe( + Route.params(Schema.Struct({ slug: Schema.String })), + Route.static({ + paths: () => + Effect.gen(function* () { + const fs = yield* FileSystem; + const slugs = yield* fs.listPostSlugs(); + return slugs.map((slug) => ({ slug })); + }), + load: ({ params }) => + Effect.gen(function* () { + const fs = yield* FileSystem; + return yield* fs.readPost(params.slug); + }), + render: (post) => PostPage({ post }), + }), +); + +export const router = Router.empty.pipe( + Router.concat(HomeRoute), + Router.concat(PostRoute), + Router.fallback(() => NotFoundPage()), +); +``` + +### SSG Build Entry + +```ts +// entry.ts — consumed by @effex/vite-plugin in ssg mode +import { Layer } from "effect"; + +import { App } from "./App.js"; +import { router } from "./routes.js"; +import { FileSystemLive } from "./services/FileSystem.js"; + +export { router }; +export const app = App; +export const document = { + title: "My Site", + scripts: ["/src/client.ts"], +}; +export const layers = FileSystemLive; // services needed by load() functions +``` + +### Vite Config + +```ts +// 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 + +```bash +pnpm build +``` + +The plugin runs `vite build` (client bundle) followed by `vite build --ssr src/entry.ts` (build-time SSR module), then invokes `Platform.buildStaticSite` to render every `Route.static` route to HTML in `dist/`. + +### Output Structure + +``` +dist/ +├── index.html # Home route +├── posts/ +│ ├── hello-world/ +│ │ └── index.html +│ └── another-post/ +│ └── index.html +├── 404.html # From router.fallback +└── assets/ + └── client-[hash].js # Client bundle for hydration +``` + +### Hydration + +Same client entry as SSR. The static HTML embeds loader data via `window.__EFFEX_DATA__`, which the client picks up via `Platform.makeClientLayer`: + +```ts +// client.ts +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), +}); +``` + +After hydration, the site behaves identically to an SSR-rendered page — Signal subscriptions are wired, event handlers attached, animations run. + ## Server API ### `Platform.toHttpRoutes(router, options?)` @@ -319,9 +457,11 @@ export default defineConfig({ }); ``` -**Dev mode:** Intercepts requests to the Vite dev server and delegates to your SSR entry, with HMR support. +**Dev mode:** Intercepts requests to the Vite dev server and delegates to your SSR or SSG entry, with HMR support. + +**Client builds:** Strips server-only code from the client bundle — loader function bodies are removed from `Route.get()` calls, mutation handlers in `Route.post/put/delete()` are replaced with stubs, and `Route.static({...})` config (build-time `paths`/`load`) is reduced to its render function. -**Client builds:** Strips server-only code from the client bundle — loader function bodies are removed from `Route.get()` calls, and mutation handlers in `Route.post/put/delete()` are replaced with stubs. +**SSG mode:** After the SSR build completes, the plugin invokes `Platform.buildStaticSite` to enumerate `Route.static` routes, run their loaders, render to HTML, and write the output to `dist/`. ## API Reference @@ -329,7 +469,8 @@ export default defineConfig({ | Function | Description | |---|---| -| `Platform.toHttpRoutes(router, options?)` | Convert Effex Router to `@effect/platform` HttpRouter | +| `Platform.toHttpRoutes(router, options?)` | Convert Effex Router to `@effect/platform` HttpRouter (SSR) | +| `Platform.buildStaticSite(options)` | Pre-render all `Route.static` routes to static HTML files (SSG) | | `Platform.makeClientLayer(router)` | Create client-side Layer for hydration and navigation | | `Platform.generateDocument(html, data, options?)` | Wrap HTML in full document with hydration data | | `Platform.generateLoaderDataScript(data)` | Generate `