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.
- package/index.js +348 -0
- package/package.json +50 -0
- package/templates/basic/.oxfmtrc.json +9 -0
- package/templates/basic/.oxlintrc.json +6 -0
- package/templates/basic/README.md +35 -0
- package/templates/basic/_gitignore +4 -0
- package/templates/basic/package.json +44 -0
- package/templates/basic/server.mjs +46 -0
- package/templates/basic/src/actions/counter.ts +11 -0
- package/templates/basic/src/components/Counter.tsx +50 -0
- package/templates/basic/src/components/Document.tsx +78 -0
- package/templates/basic/src/components/Fallbacks.tsx +36 -0
- package/templates/basic/src/components/pages/AboutPage.tsx +21 -0
- package/templates/basic/src/components/pages/CounterPage.tsx +27 -0
- package/templates/basic/src/components/pages/GreetingPage.tsx +24 -0
- package/templates/basic/src/components/pages/HomePage.tsx +52 -0
- package/templates/basic/src/data/counter.ts +11 -0
- package/templates/basic/src/router.gen.ts +8 -0
- package/templates/basic/src/router.named-routes.gen.ts +17 -0
- package/templates/basic/src/router.tsx +33 -0
- package/templates/basic/src/styles.css +5 -0
- package/templates/basic/test/router.test.ts +8 -0
- package/templates/basic/tsconfig.json +19 -0
- package/templates/basic/vite.config.ts +17 -0
- package/templates/basic/vitest.config.ts +10 -0
- package/templates/basic-js/.oxfmtrc.json +9 -0
- package/templates/basic-js/.oxlintrc.json +6 -0
- package/templates/basic-js/README.md +38 -0
- package/templates/basic-js/_gitignore +4 -0
- package/templates/basic-js/package.json +40 -0
- package/templates/basic-js/server.mjs +46 -0
- package/templates/basic-js/src/actions/counter.js +11 -0
- package/templates/basic-js/src/components/Counter.jsx +50 -0
- package/templates/basic-js/src/components/Document.jsx +78 -0
- package/templates/basic-js/src/components/Fallbacks.jsx +36 -0
- package/templates/basic-js/src/components/pages/AboutPage.jsx +21 -0
- package/templates/basic-js/src/components/pages/CounterPage.jsx +27 -0
- package/templates/basic-js/src/components/pages/GreetingPage.jsx +24 -0
- package/templates/basic-js/src/components/pages/HomePage.jsx +52 -0
- package/templates/basic-js/src/data/counter.js +11 -0
- package/templates/basic-js/src/router.gen.ts +8 -0
- package/templates/basic-js/src/router.jsx +25 -0
- package/templates/basic-js/src/router.named-routes.gen.ts +17 -0
- package/templates/basic-js/src/styles.css +5 -0
- package/templates/basic-js/test/router.test.js +8 -0
- package/templates/basic-js/vite.config.js +17 -0
- package/templates/basic-js/vitest.config.js +10 -0
- package/templates/cloudflare/.oxfmtrc.json +9 -0
- package/templates/cloudflare/.oxlintrc.json +6 -0
- package/templates/cloudflare/README.md +40 -0
- package/templates/cloudflare/_gitignore +6 -0
- package/templates/cloudflare/package.json +44 -0
- package/templates/cloudflare/src/actions/counter.ts +11 -0
- package/templates/cloudflare/src/components/Counter.tsx +50 -0
- package/templates/cloudflare/src/components/Document.tsx +78 -0
- package/templates/cloudflare/src/components/Fallbacks.tsx +36 -0
- package/templates/cloudflare/src/components/pages/AboutPage.tsx +21 -0
- package/templates/cloudflare/src/components/pages/CounterPage.tsx +27 -0
- package/templates/cloudflare/src/components/pages/GreetingPage.tsx +24 -0
- package/templates/cloudflare/src/components/pages/HomePage.tsx +52 -0
- package/templates/cloudflare/src/data/counter.ts +11 -0
- package/templates/cloudflare/src/env.ts +13 -0
- package/templates/cloudflare/src/router.gen.ts +8 -0
- package/templates/cloudflare/src/router.named-routes.gen.ts +17 -0
- package/templates/cloudflare/src/router.tsx +33 -0
- package/templates/cloudflare/src/styles.css +5 -0
- package/templates/cloudflare/src/worker.rsc.tsx +9 -0
- package/templates/cloudflare/test/router.test.ts +8 -0
- package/templates/cloudflare/tsconfig.json +19 -0
- package/templates/cloudflare/vite.config.ts +17 -0
- package/templates/cloudflare/vitest.config.ts +10 -0
- package/templates/cloudflare/wrangler.json +9 -0
- package/templates/vercel/.oxfmtrc.json +9 -0
- package/templates/vercel/.oxlintrc.json +6 -0
- package/templates/vercel/README.md +50 -0
- package/templates/vercel/_gitignore +5 -0
- package/templates/vercel/package.json +43 -0
- package/templates/vercel/scripts/preview.mjs +23 -0
- package/templates/vercel/scripts/serve-vercel-output.mjs +67 -0
- package/templates/vercel/src/actions/counter.ts +11 -0
- package/templates/vercel/src/components/Counter.tsx +50 -0
- package/templates/vercel/src/components/Document.tsx +78 -0
- package/templates/vercel/src/components/Fallbacks.tsx +36 -0
- package/templates/vercel/src/components/pages/AboutPage.tsx +21 -0
- package/templates/vercel/src/components/pages/CounterPage.tsx +27 -0
- package/templates/vercel/src/components/pages/GreetingPage.tsx +24 -0
- package/templates/vercel/src/components/pages/HomePage.tsx +52 -0
- package/templates/vercel/src/data/counter.ts +11 -0
- package/templates/vercel/src/router.gen.ts +8 -0
- package/templates/vercel/src/router.named-routes.gen.ts +17 -0
- package/templates/vercel/src/router.tsx +33 -0
- package/templates/vercel/src/styles.css +5 -0
- package/templates/vercel/test/router.test.ts +8 -0
- package/templates/vercel/tsconfig.json +19 -0
- package/templates/vercel/vite.config.ts +17 -0
- 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,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,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,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,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
|
+
};
|