create-rango 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (96) hide show
  1. package/index.js +348 -0
  2. package/package.json +50 -0
  3. package/templates/basic/.oxfmtrc.json +9 -0
  4. package/templates/basic/.oxlintrc.json +6 -0
  5. package/templates/basic/README.md +35 -0
  6. package/templates/basic/_gitignore +4 -0
  7. package/templates/basic/package.json +44 -0
  8. package/templates/basic/server.mjs +46 -0
  9. package/templates/basic/src/actions/counter.ts +11 -0
  10. package/templates/basic/src/components/Counter.tsx +50 -0
  11. package/templates/basic/src/components/Document.tsx +78 -0
  12. package/templates/basic/src/components/Fallbacks.tsx +36 -0
  13. package/templates/basic/src/components/pages/AboutPage.tsx +21 -0
  14. package/templates/basic/src/components/pages/CounterPage.tsx +27 -0
  15. package/templates/basic/src/components/pages/GreetingPage.tsx +24 -0
  16. package/templates/basic/src/components/pages/HomePage.tsx +52 -0
  17. package/templates/basic/src/data/counter.ts +11 -0
  18. package/templates/basic/src/router.gen.ts +8 -0
  19. package/templates/basic/src/router.named-routes.gen.ts +17 -0
  20. package/templates/basic/src/router.tsx +33 -0
  21. package/templates/basic/src/styles.css +5 -0
  22. package/templates/basic/test/router.test.ts +8 -0
  23. package/templates/basic/tsconfig.json +19 -0
  24. package/templates/basic/vite.config.ts +17 -0
  25. package/templates/basic/vitest.config.ts +10 -0
  26. package/templates/basic-js/.oxfmtrc.json +9 -0
  27. package/templates/basic-js/.oxlintrc.json +6 -0
  28. package/templates/basic-js/README.md +38 -0
  29. package/templates/basic-js/_gitignore +4 -0
  30. package/templates/basic-js/package.json +40 -0
  31. package/templates/basic-js/server.mjs +46 -0
  32. package/templates/basic-js/src/actions/counter.js +11 -0
  33. package/templates/basic-js/src/components/Counter.jsx +50 -0
  34. package/templates/basic-js/src/components/Document.jsx +78 -0
  35. package/templates/basic-js/src/components/Fallbacks.jsx +36 -0
  36. package/templates/basic-js/src/components/pages/AboutPage.jsx +21 -0
  37. package/templates/basic-js/src/components/pages/CounterPage.jsx +27 -0
  38. package/templates/basic-js/src/components/pages/GreetingPage.jsx +24 -0
  39. package/templates/basic-js/src/components/pages/HomePage.jsx +52 -0
  40. package/templates/basic-js/src/data/counter.js +11 -0
  41. package/templates/basic-js/src/router.gen.ts +8 -0
  42. package/templates/basic-js/src/router.jsx +25 -0
  43. package/templates/basic-js/src/router.named-routes.gen.ts +17 -0
  44. package/templates/basic-js/src/styles.css +5 -0
  45. package/templates/basic-js/test/router.test.js +8 -0
  46. package/templates/basic-js/vite.config.js +17 -0
  47. package/templates/basic-js/vitest.config.js +10 -0
  48. package/templates/cloudflare/.oxfmtrc.json +9 -0
  49. package/templates/cloudflare/.oxlintrc.json +6 -0
  50. package/templates/cloudflare/README.md +40 -0
  51. package/templates/cloudflare/_gitignore +6 -0
  52. package/templates/cloudflare/package.json +44 -0
  53. package/templates/cloudflare/src/actions/counter.ts +11 -0
  54. package/templates/cloudflare/src/components/Counter.tsx +50 -0
  55. package/templates/cloudflare/src/components/Document.tsx +78 -0
  56. package/templates/cloudflare/src/components/Fallbacks.tsx +36 -0
  57. package/templates/cloudflare/src/components/pages/AboutPage.tsx +21 -0
  58. package/templates/cloudflare/src/components/pages/CounterPage.tsx +27 -0
  59. package/templates/cloudflare/src/components/pages/GreetingPage.tsx +24 -0
  60. package/templates/cloudflare/src/components/pages/HomePage.tsx +52 -0
  61. package/templates/cloudflare/src/data/counter.ts +11 -0
  62. package/templates/cloudflare/src/env.ts +13 -0
  63. package/templates/cloudflare/src/router.gen.ts +8 -0
  64. package/templates/cloudflare/src/router.named-routes.gen.ts +17 -0
  65. package/templates/cloudflare/src/router.tsx +33 -0
  66. package/templates/cloudflare/src/styles.css +5 -0
  67. package/templates/cloudflare/src/worker.rsc.tsx +9 -0
  68. package/templates/cloudflare/test/router.test.ts +8 -0
  69. package/templates/cloudflare/tsconfig.json +19 -0
  70. package/templates/cloudflare/vite.config.ts +17 -0
  71. package/templates/cloudflare/vitest.config.ts +10 -0
  72. package/templates/cloudflare/wrangler.json +9 -0
  73. package/templates/vercel/.oxfmtrc.json +9 -0
  74. package/templates/vercel/.oxlintrc.json +6 -0
  75. package/templates/vercel/README.md +50 -0
  76. package/templates/vercel/_gitignore +5 -0
  77. package/templates/vercel/package.json +43 -0
  78. package/templates/vercel/scripts/preview.mjs +23 -0
  79. package/templates/vercel/scripts/serve-vercel-output.mjs +67 -0
  80. package/templates/vercel/src/actions/counter.ts +11 -0
  81. package/templates/vercel/src/components/Counter.tsx +50 -0
  82. package/templates/vercel/src/components/Document.tsx +78 -0
  83. package/templates/vercel/src/components/Fallbacks.tsx +36 -0
  84. package/templates/vercel/src/components/pages/AboutPage.tsx +21 -0
  85. package/templates/vercel/src/components/pages/CounterPage.tsx +27 -0
  86. package/templates/vercel/src/components/pages/GreetingPage.tsx +24 -0
  87. package/templates/vercel/src/components/pages/HomePage.tsx +52 -0
  88. package/templates/vercel/src/data/counter.ts +11 -0
  89. package/templates/vercel/src/router.gen.ts +8 -0
  90. package/templates/vercel/src/router.named-routes.gen.ts +17 -0
  91. package/templates/vercel/src/router.tsx +33 -0
  92. package/templates/vercel/src/styles.css +5 -0
  93. package/templates/vercel/test/router.test.ts +8 -0
  94. package/templates/vercel/tsconfig.json +19 -0
  95. package/templates/vercel/vite.config.ts +17 -0
  96. package/templates/vercel/vitest.config.ts +10 -0
@@ -0,0 +1,13 @@
1
+ /// <reference types="@cloudflare/workers-types" />
2
+
3
+ // Cloudflare Workers bindings. Add entries here as you bind resources in
4
+ // wrangler.json, e.g.:
5
+ // KV: KVNamespace;
6
+ // DB: D1Database;
7
+ export interface AppBindings {}
8
+
9
+ declare global {
10
+ namespace Rango {
11
+ interface Env extends AppBindings {}
12
+ }
13
+ }
@@ -0,0 +1,8 @@
1
+ // Auto-generated by @rangojs/router - do not edit
2
+ export const routes = {
3
+ about: "/about",
4
+ counter: "/counter",
5
+ greeting: "/hello/:name",
6
+ home: "/",
7
+ } as const;
8
+ export type routes = typeof routes;
@@ -0,0 +1,17 @@
1
+ // Auto-generated by @rangojs/router - do not edit
2
+ export const NamedRoutes = {
3
+ about: "/about",
4
+ counter: "/counter",
5
+ greeting: "/hello/:name",
6
+ home: "/",
7
+ } as const;
8
+
9
+ // Aliased so the augmentation below does not pay a homomorphic mapped-type
10
+ // instantiation per route; `as const` already makes the members readonly.
11
+ type NamedRoutesShape = typeof NamedRoutes;
12
+
13
+ declare global {
14
+ namespace Rango {
15
+ interface GeneratedRouteMap extends NamedRoutesShape {}
16
+ }
17
+ }
@@ -0,0 +1,33 @@
1
+ import { createRouter, urls } from "@rangojs/router";
2
+ import { updateCount } from "./actions/counter.js";
3
+ import { Document } from "./components/Document.js";
4
+ import { ErrorFallback, NotFoundPage } from "./components/Fallbacks.js";
5
+ import { HomePage } from "./components/pages/HomePage.js";
6
+ import { AboutPage } from "./components/pages/AboutPage.js";
7
+ import { GreetingPage } from "./components/pages/GreetingPage.js";
8
+ import { CounterPage } from "./components/pages/CounterPage.js";
9
+
10
+ export const urlpatterns = urls(({ path, revalidate }) => [
11
+ path("/", HomePage, { name: "home" }),
12
+ path("/about", AboutPage, { name: "about" }),
13
+ path("/hello/:name", GreetingPage, { name: "greeting" }),
14
+ path("/counter", CounterPage, { name: "counter" }, () => [
15
+ revalidate((ctx) => ctx.isAction(updateCount) || undefined),
16
+ ]),
17
+ ]);
18
+
19
+ export const router = createRouter({
20
+ document: Document,
21
+ urls: urlpatterns,
22
+ defaultErrorBoundary: ErrorFallback,
23
+ defaultNotFoundBoundary: NotFoundPage,
24
+ notFound: NotFoundPage,
25
+ });
26
+
27
+ type AppRoutes = typeof router.routeMap;
28
+
29
+ declare global {
30
+ namespace Rango {
31
+ interface RegisteredRoutes extends AppRoutes {}
32
+ }
33
+ }
@@ -0,0 +1,5 @@
1
+ @import "tailwindcss";
2
+
3
+ :root {
4
+ color-scheme: light dark;
5
+ }
@@ -0,0 +1,9 @@
1
+ /// <reference types="@cloudflare/workers-types" />
2
+ import { router } from "./router.js";
3
+ import type { AppBindings } from "./env.js";
4
+
5
+ export default {
6
+ async fetch(request, env, ctx) {
7
+ return router.fetch(request, { env, ctx });
8
+ },
9
+ } satisfies ExportedHandler<AppBindings>;
@@ -0,0 +1,8 @@
1
+ import { assertGeneratedRoutesMatch } from "@rangojs/router/testing";
2
+ import { test } from "vitest";
3
+ import { NamedRoutes } from "../src/router.named-routes.gen.js";
4
+ import { router } from "../src/router.js";
5
+
6
+ test("generated routes match the runtime router", async () => {
7
+ await assertGeneratedRoutesMatch(router, NamedRoutes);
8
+ });
@@ -0,0 +1,19 @@
1
+ {
2
+ "compilerOptions": {
3
+ "target": "ES2022",
4
+ "lib": ["ES2023", "DOM", "DOM.Iterable"],
5
+ "module": "ESNext",
6
+ "moduleResolution": "bundler",
7
+ "moduleDetection": "force",
8
+ "allowImportingTsExtensions": true,
9
+ "isolatedModules": true,
10
+ "noEmit": true,
11
+ "jsx": "react-jsx",
12
+ "strict": true,
13
+ "skipLibCheck": true,
14
+ "noFallthroughCasesInSwitch": true,
15
+ "noUncheckedSideEffectImports": true,
16
+ "types": ["@cloudflare/workers-types", "vite/client"]
17
+ },
18
+ "include": ["src", "vite.config.ts"]
19
+ }
@@ -0,0 +1,17 @@
1
+ import { defineConfig } from "vite";
2
+ import react from "@vitejs/plugin-react";
3
+ import tailwindcss from "@tailwindcss/vite";
4
+ import { cloudflare } from "@cloudflare/vite-plugin";
5
+ import { rango } from "@rangojs/router/vite";
6
+
7
+ export default defineConfig({
8
+ plugins: [
9
+ react(),
10
+ tailwindcss(),
11
+ rango({ preset: "cloudflare" }),
12
+ cloudflare({
13
+ configPath: "./wrangler.json",
14
+ viteEnvironment: { name: "rsc", childEnvironments: ["ssr"] },
15
+ }),
16
+ ],
17
+ });
@@ -0,0 +1,10 @@
1
+ import { rangoTestConfig } from "@rangojs/router/testing/vitest";
2
+ import { defineConfig } from "vitest/config";
3
+
4
+ export default defineConfig({
5
+ test: {
6
+ environment: "node",
7
+ include: ["test/**/*.test.{ts,tsx}"],
8
+ ...rangoTestConfig({ preset: "cloudflare" }),
9
+ },
10
+ });
@@ -0,0 +1,9 @@
1
+ {
2
+ "name": "rango-app",
3
+ "compatibility_date": "2026-07-12",
4
+ "compatibility_flags": ["nodejs_compat", "nodejs_als"],
5
+ "main": "./src/worker.rsc.tsx",
6
+ "assets": {
7
+ "directory": "./dist/client"
8
+ }
9
+ }
@@ -0,0 +1,9 @@
1
+ {
2
+ "printWidth": 80,
3
+ "tabWidth": 2,
4
+ "useTabs": false,
5
+ "semi": true,
6
+ "singleQuote": false,
7
+ "trailingComma": "all",
8
+ "ignorePatterns": ["dist/", "node_modules/", ".vite/", ".vercel/"]
9
+ }
@@ -0,0 +1,6 @@
1
+ {
2
+ "plugins": ["typescript"],
3
+ "categories": { "correctness": "error" },
4
+ "rules": { "typescript/triple-slash-reference": "off" },
5
+ "ignorePatterns": ["dist/", "node_modules/", ".vite/", ".vercel/"]
6
+ }
@@ -0,0 +1,50 @@
1
+ # Rango app on Vercel
2
+
3
+ A [React Server Components](https://react.dev/reference/rsc/server-components) app powered by [`@rangojs/router`](https://www.npmjs.com/package/@rangojs/router), deployed to Vercel with Vite and Tailwind CSS.
4
+
5
+ ## Commands
6
+
7
+ ```sh
8
+ npm run dev # start the dev server
9
+ npm run build # vite build, then assembles .vercel/output (Build Output API v3)
10
+ npm run preview # serve .vercel/output with filesystem/function routing
11
+ npm run generate # regenerate committed route maps
12
+ npm test # run unit tests
13
+ npm run typecheck # tsc --noEmit
14
+ npm run lint # oxlint
15
+ npm run format # oxfmt check
16
+ npm run check # typecheck + tests + lint + format
17
+ ```
18
+
19
+ Requires Node.js 24 or newer.
20
+
21
+ ## Deploying
22
+
23
+ The `vercel` preset assembles `.vercel/output/` during `npm run build`, so deploy prebuilt:
24
+
25
+ ```sh
26
+ npm run build
27
+ npx vercel@latest deploy --prebuilt # preview deployment
28
+ npx vercel@latest deploy --prebuilt --prod # production
29
+ ```
30
+
31
+ Pushing the repo to Vercel with the default Vite framework settings also works: the build output is picked up from `.vercel/output`.
32
+
33
+ ## Project layout
34
+
35
+ - `src/router.tsx` — the router: URL patterns, route names, and the document component. The Vite plugin auto-discovers it; there is no `index.html` or entry file.
36
+ - `src/components/Document.tsx` — the HTML shell (client component). Tailwind is wired here via `styles.css?url`.
37
+ - `src/components/pages/` — route handlers (server functions receiving a `HandlerContext`).
38
+ - `src/actions/` — `"use server"` functions callable from client components.
39
+ - `test/router.test.ts` — route-map drift test using `@rangojs/router/testing`.
40
+ - `src/router.named-routes.gen.ts` — generated global route names for `Handler<"name">` and `ctx.reverse()`; commit it.
41
+ - `src/router.gen.ts` — generated local route map used by `useReverse()` in client components; run `npm run generate` after route changes and commit it.
42
+ - `scripts/preview.mjs` — serves the assembled Vercel output locally, including the streaming function.
43
+
44
+ ## Why is `esbuild` in devDependencies?
45
+
46
+ The vercel preset bundles the generated function launcher with esbuild at build time. Vite 8 no longer ships esbuild (it is rolldown-based), so the app must provide it.
47
+
48
+ ## Note on the counter demo
49
+
50
+ The demo count lives in function instance memory, so it resets across invocations/instances. Use a database or Vercel KV-style store for real persistence.
@@ -0,0 +1,5 @@
1
+ node_modules
2
+ dist
3
+ .vercel
4
+ .env*
5
+ *.local
@@ -0,0 +1,43 @@
1
+ {
2
+ "name": "rango-template-vercel",
3
+ "version": "0.0.0",
4
+ "private": true,
5
+ "type": "module",
6
+ "scripts": {
7
+ "predev": "rango generate src/",
8
+ "dev": "vite",
9
+ "prebuild": "rango generate src/",
10
+ "build": "vite build",
11
+ "preview": "node scripts/preview.mjs",
12
+ "generate": "rango generate src/",
13
+ "test": "vitest run",
14
+ "typecheck": "tsc --noEmit",
15
+ "lint": "oxlint --deny-warnings .",
16
+ "format": "oxfmt --check .",
17
+ "format:fix": "oxfmt .",
18
+ "check": "tsc --noEmit && vitest run && oxlint --deny-warnings . && oxfmt --check ."
19
+ },
20
+ "dependencies": {
21
+ "@rangojs/router": "^0.4.0",
22
+ "@vercel/functions": "^3.7.1",
23
+ "react": "^19.2.6",
24
+ "react-dom": "^19.2.6"
25
+ },
26
+ "devDependencies": {
27
+ "@tailwindcss/vite": "^4.3.1",
28
+ "@types/node": "^24.10.0",
29
+ "@types/react": "^19.2.7",
30
+ "@types/react-dom": "^19.2.3",
31
+ "@vitejs/plugin-react": "^6.0.0",
32
+ "esbuild": "^0.28.1",
33
+ "oxfmt": "^0.58.0",
34
+ "oxlint": "^1.73.0",
35
+ "tailwindcss": "^4.3.1",
36
+ "typescript": "^5.9.3",
37
+ "vite": "^8.0.16",
38
+ "vitest": "^4.1.10"
39
+ },
40
+ "engines": {
41
+ "node": ">=24.0.0"
42
+ }
43
+ }
@@ -0,0 +1,23 @@
1
+ import path from "node:path";
2
+ import { fileURLToPath } from "node:url";
3
+ import { createVercelOutputServer } from "./serve-vercel-output.mjs";
4
+
5
+ const appRoot = path.resolve(fileURLToPath(import.meta.url), "../..");
6
+ const server = await createVercelOutputServer(
7
+ path.join(appRoot, ".vercel", "output"),
8
+ );
9
+ const port = Number(process.env.PORT ?? 3000);
10
+ const host = process.env.HOST ?? "127.0.0.1";
11
+
12
+ server.listen(port, host, () => {
13
+ console.log(`Vercel preview: http://${host}:${server.address().port}`);
14
+ });
15
+
16
+ function shutdown() {
17
+ const forceClose = setTimeout(() => server.closeAllConnections(), 10_000);
18
+ forceClose.unref();
19
+ server.close(() => clearTimeout(forceClose));
20
+ }
21
+
22
+ process.once("SIGTERM", shutdown);
23
+ process.once("SIGINT", shutdown);
@@ -0,0 +1,67 @@
1
+ import http from "node:http";
2
+ import { readFile, stat } from "node:fs/promises";
3
+ import path from "node:path";
4
+ import { pathToFileURL } from "node:url";
5
+
6
+ const CONTENT_TYPE = {
7
+ ".css": "text/css",
8
+ ".ico": "image/x-icon",
9
+ ".js": "text/javascript",
10
+ ".json": "application/json",
11
+ ".map": "application/json",
12
+ ".mjs": "text/javascript",
13
+ ".svg": "image/svg+xml",
14
+ };
15
+
16
+ export async function createVercelOutputServer(outputDir) {
17
+ const functionEntry = path.join(
18
+ outputDir,
19
+ "functions",
20
+ "index.func",
21
+ "index.mjs",
22
+ );
23
+ const staticDir = path.join(outputDir, "static");
24
+ const handler = (await import(pathToFileURL(functionEntry).href)).default;
25
+
26
+ return http.createServer(async (request, response) => {
27
+ try {
28
+ const pathname = decodeURIComponent(
29
+ new URL(request.url, "http://localhost").pathname,
30
+ );
31
+ if (pathname !== "/") {
32
+ const filePath = path.resolve(staticDir, pathname.replace(/^\/+/, ""));
33
+ const relativePath = path.relative(staticDir, filePath);
34
+ if (
35
+ relativePath &&
36
+ !relativePath.startsWith("..") &&
37
+ !path.isAbsolute(relativePath)
38
+ ) {
39
+ try {
40
+ const info = await stat(filePath);
41
+ if (info.isFile()) {
42
+ response.setHeader(
43
+ "content-type",
44
+ CONTENT_TYPE[path.extname(filePath)] ??
45
+ "application/octet-stream",
46
+ );
47
+ response.end(await readFile(filePath));
48
+ return;
49
+ }
50
+ } catch {
51
+ // Missing static files fall through to the function.
52
+ }
53
+ }
54
+ }
55
+ await handler(request, response);
56
+ } catch (error) {
57
+ if (!response.headersSent) {
58
+ response.statusCode = error instanceof URIError ? 400 : 500;
59
+ response.end();
60
+ } else {
61
+ response.destroy(
62
+ error instanceof Error ? error : new Error(String(error)),
63
+ );
64
+ }
65
+ }
66
+ });
67
+ }
@@ -0,0 +1,11 @@
1
+ "use server";
2
+
3
+ import { updateCount as persistCount } from "../data/counter.js";
4
+
5
+ export async function updateCount(formData: FormData): Promise<void> {
6
+ const delta = formData.get("delta");
7
+ if (delta !== "-1" && delta !== "1") {
8
+ throw new Error("Invalid counter change");
9
+ }
10
+ persistCount(Number(delta));
11
+ }
@@ -0,0 +1,50 @@
1
+ "use client";
2
+
3
+ import { useFormStatus } from "react-dom";
4
+ import { updateCount } from "../actions/counter.js";
5
+
6
+ export function Counter({ initialCount }: { initialCount: number }) {
7
+ return (
8
+ <form action={updateCount}>
9
+ <CounterControls count={initialCount} />
10
+ </form>
11
+ );
12
+ }
13
+
14
+ function CounterControls({ count }: { count: number }) {
15
+ const { pending } = useFormStatus();
16
+ return (
17
+ <div className="flex items-center gap-4" aria-busy={pending}>
18
+ <button
19
+ type="submit"
20
+ name="delta"
21
+ value="-1"
22
+ disabled={pending}
23
+ aria-label="Decrease count"
24
+ className="rounded-md bg-zinc-900 px-4 py-2 text-sm font-medium text-white hover:bg-zinc-700 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600 disabled:opacity-50 dark:bg-zinc-100 dark:text-zinc-900 dark:hover:bg-zinc-300 dark:focus-visible:outline-blue-400"
25
+ >
26
+ −1
27
+ </button>
28
+ <output
29
+ aria-live="polite"
30
+ className="min-w-16 text-center text-2xl font-semibold tabular-nums"
31
+ style={{ opacity: pending ? 0.5 : 1 }}
32
+ >
33
+ {count}
34
+ </output>
35
+ <button
36
+ type="submit"
37
+ name="delta"
38
+ value="1"
39
+ disabled={pending}
40
+ aria-label="Increase count"
41
+ className="rounded-md bg-zinc-900 px-4 py-2 text-sm font-medium text-white hover:bg-zinc-700 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600 disabled:opacity-50 dark:bg-zinc-100 dark:text-zinc-900 dark:hover:bg-zinc-300 dark:focus-visible:outline-blue-400"
42
+ >
43
+ +1
44
+ </button>
45
+ <span className="sr-only" role="status">
46
+ {pending ? "Updating count" : ""}
47
+ </span>
48
+ </div>
49
+ );
50
+ }
@@ -0,0 +1,78 @@
1
+ "use client";
2
+
3
+ import { useEffect, useRef, type ReactNode } from "react";
4
+ import {
5
+ Link,
6
+ MetaTags,
7
+ useNavigation,
8
+ usePathname,
9
+ useReverse,
10
+ } from "@rangojs/router/client";
11
+ import { routes } from "../router.gen.js";
12
+ import styles from "../styles.css?url";
13
+
14
+ export function Document({ children }: { children: ReactNode }) {
15
+ const pathname = usePathname();
16
+ const reverse = useReverse(routes);
17
+ const isNavigating = useNavigation(
18
+ (navigation) => navigation.state !== "idle",
19
+ );
20
+ const contentRef = useRef<HTMLDivElement>(null);
21
+ const previousPathname = useRef(pathname);
22
+
23
+ useEffect(() => {
24
+ if (previousPathname.current !== pathname) {
25
+ contentRef.current?.focus();
26
+ previousPathname.current = pathname;
27
+ }
28
+ }, [pathname]);
29
+
30
+ const links = [
31
+ [reverse("home"), "Home"],
32
+ [reverse("about"), "About"],
33
+ [reverse("counter"), "Counter"],
34
+ ] as const;
35
+
36
+ return (
37
+ <html lang="en">
38
+ <head>
39
+ <MetaTags />
40
+ <link rel="preload" as="style" href={styles} precedence="default" />
41
+ <link rel="stylesheet" href={styles} precedence="default" />
42
+ </head>
43
+ <body className="min-h-dvh bg-white text-zinc-900 antialiased dark:bg-zinc-950 dark:text-zinc-100">
44
+ <div className="mx-auto max-w-2xl px-6 py-10">
45
+ <a
46
+ href="#main-content"
47
+ className="sr-only rounded bg-white px-3 py-2 text-zinc-950 focus:not-sr-only focus:absolute focus:left-4 focus:top-4 focus:z-10"
48
+ >
49
+ Skip to content
50
+ </a>
51
+ <nav className="mb-10 flex items-center gap-6 border-b border-zinc-200 pb-4 text-sm font-medium dark:border-zinc-800">
52
+ {links.map(([to, label]) => (
53
+ <Link
54
+ key={to}
55
+ to={to}
56
+ aria-current={pathname === to ? "page" : undefined}
57
+ className="hover:text-blue-600 focus-visible:outline-2 focus-visible:outline-offset-4 focus-visible:outline-blue-600 dark:hover:text-blue-400 dark:focus-visible:outline-blue-400"
58
+ >
59
+ {label}
60
+ </Link>
61
+ ))}
62
+ </nav>
63
+ <span className="sr-only" aria-live="polite">
64
+ {isNavigating ? "Loading page" : ""}
65
+ </span>
66
+ <div
67
+ id="main-content"
68
+ ref={contentRef}
69
+ tabIndex={-1}
70
+ className="outline-none"
71
+ >
72
+ {children}
73
+ </div>
74
+ </div>
75
+ </body>
76
+ </html>
77
+ );
78
+ }
@@ -0,0 +1,36 @@
1
+ import { Link, href } from "@rangojs/router/client";
2
+
3
+ const linkClass =
4
+ "mt-6 inline-flex font-medium text-blue-600 underline-offset-4 hover:underline focus-visible:outline-2 focus-visible:outline-offset-4 focus-visible:outline-blue-600 dark:text-blue-400 dark:focus-visible:outline-blue-400";
5
+
6
+ export function ErrorFallback() {
7
+ return (
8
+ <main>
9
+ <div role="alert">
10
+ <h1 className="text-3xl font-semibold tracking-tight">
11
+ Something went wrong
12
+ </h1>
13
+ <p className="mt-4 leading-7 text-zinc-600 dark:text-zinc-400">
14
+ The page could not be rendered. Try again or return home.
15
+ </p>
16
+ </div>
17
+ <Link to={href("/")} className={linkClass}>
18
+ Return home
19
+ </Link>
20
+ </main>
21
+ );
22
+ }
23
+
24
+ export function NotFoundPage() {
25
+ return (
26
+ <main>
27
+ <h1 className="text-3xl font-semibold tracking-tight">Page not found</h1>
28
+ <p className="mt-4 leading-7 text-zinc-600 dark:text-zinc-400">
29
+ The page you requested does not exist.
30
+ </p>
31
+ <Link to={href("/")} className={linkClass}>
32
+ Return home
33
+ </Link>
34
+ </main>
35
+ );
36
+ }
@@ -0,0 +1,21 @@
1
+ import { Meta, type HandlerContext } from "@rangojs/router";
2
+
3
+ export function AboutPage(ctx: HandlerContext) {
4
+ const meta = ctx.use(Meta);
5
+ meta({ title: "About — Rango" });
6
+ meta({
7
+ name: "description",
8
+ content: "How this Rango starter structures routes and server components",
9
+ });
10
+
11
+ return (
12
+ <main>
13
+ <h1 className="text-3xl font-semibold tracking-tight">About</h1>
14
+ <p className="mt-4 leading-7 text-zinc-600 dark:text-zinc-400">
15
+ Routes are plain functions receiving a handler context. Register them in{" "}
16
+ <code>src/router.tsx</code> with Django-style URL patterns; route names
17
+ generate typed <code>href()</code> lookups as you save.
18
+ </p>
19
+ </main>
20
+ );
21
+ }
@@ -0,0 +1,27 @@
1
+ import { Meta, type HandlerContext } from "@rangojs/router";
2
+ import { Counter } from "../Counter.js";
3
+ import { getCount } from "../../data/counter.js";
4
+
5
+ export function CounterPage(ctx: HandlerContext) {
6
+ const meta = ctx.use(Meta);
7
+ meta({ title: "Counter — Rango" });
8
+ meta({
9
+ name: "description",
10
+ content: "A progressively enhanced React server action counter",
11
+ });
12
+
13
+ const initialCount = getCount();
14
+
15
+ return (
16
+ <main>
17
+ <h1 className="text-3xl font-semibold tracking-tight">Counter</h1>
18
+ <p className="mt-4 leading-7 text-zinc-600 dark:text-zinc-400">
19
+ The count lives on the server. The form works before JavaScript loads,
20
+ then React adds pending feedback without changing the action.
21
+ </p>
22
+ <div className="mt-8">
23
+ <Counter initialCount={initialCount} />
24
+ </div>
25
+ </main>
26
+ );
27
+ }
@@ -0,0 +1,24 @@
1
+ import { Meta, type Handler } from "@rangojs/router";
2
+
3
+ export const GreetingPage: Handler<"greeting"> = (ctx) => {
4
+ const name = ctx.params.name;
5
+
6
+ const meta = ctx.use(Meta);
7
+ meta({ title: `Hello ${name} — Rango` });
8
+ meta({
9
+ name: "description",
10
+ content: `A typed dynamic route greeting ${name}`,
11
+ });
12
+
13
+ return (
14
+ <main>
15
+ <h1 className="break-words text-3xl font-semibold tracking-tight">
16
+ Hello, {name}!
17
+ </h1>
18
+ <p className="mt-4 leading-7 text-zinc-600 dark:text-zinc-400">
19
+ This page matched <code>/hello/:name</code> and read the param from{" "}
20
+ <code>ctx.params</code> on the server.
21
+ </p>
22
+ </main>
23
+ );
24
+ };