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
package/index.js ADDED
@@ -0,0 +1,348 @@
1
+ #!/usr/bin/env node
2
+ import fs from "node:fs";
3
+ import path from "node:path";
4
+ import { fileURLToPath } from "node:url";
5
+ import * as p from "@clack/prompts";
6
+
7
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
8
+
9
+ // Layout is selected explicitly, not by probing: installed under any package
10
+ // manager the CLI lives inside a node_modules tree and MUST use the templates
11
+ // bundled by prepack; running from the repo it MUST use the source templates
12
+ // two levels up (a leftover ./templates snapshot from a local pack would
13
+ // otherwise silently shadow them).
14
+ const IS_INSTALLED = __dirname.split(path.sep).includes("node_modules");
15
+ const TEMPLATES_ROOT = IS_INSTALLED
16
+ ? path.join(__dirname, "templates")
17
+ : path.join(__dirname, "../../templates");
18
+
19
+ const TEMPLATES = [
20
+ {
21
+ name: "basic",
22
+ label: "Basic",
23
+ hint: "Vite + RSC, deploy to any Node host",
24
+ jsFlavor: true,
25
+ },
26
+ {
27
+ name: "cloudflare",
28
+ label: "Cloudflare Workers",
29
+ hint: "Cloudflare Vite plugin + wrangler",
30
+ },
31
+ {
32
+ name: "vercel",
33
+ label: "Vercel",
34
+ hint: "Build Output API, deploy with `vercel deploy --prebuilt`",
35
+ },
36
+ ];
37
+
38
+ const PACKAGE_MANAGERS = ["npm", "pnpm"];
39
+
40
+ const HELP = `create-rango — scaffold a Rango (@rangojs/router) app
41
+
42
+ Usage:
43
+ create-rango [directory] [options]
44
+
45
+ Options:
46
+ -t, --template <name> ${TEMPLATES.map((t) => t.name).join(" | ")}
47
+ --js JavaScript flavor (basic template only)
48
+ --ts TypeScript flavor (default)
49
+ --package-manager <pm> npm | pnpm
50
+ --overwrite scaffold into a non-empty directory
51
+ -h, --help show this help
52
+ -v, --version show the create-rango version
53
+
54
+ Examples:
55
+ npm create rango@latest my-app
56
+ pnpm create rango my-app --template cloudflare
57
+ npm create rango@latest my-app -- --package-manager npm
58
+ pnpm create rango my-app -t basic --js
59
+ `;
60
+
61
+ function parseArgs(argv) {
62
+ const args = {
63
+ dir: undefined,
64
+ template: undefined,
65
+ lang: undefined,
66
+ packageManager: undefined,
67
+ };
68
+ for (let i = 0; i < argv.length; i++) {
69
+ const arg = argv[i];
70
+ if (arg === "-h" || arg === "--help") args.help = true;
71
+ else if (arg === "-v" || arg === "--version") args.version = true;
72
+ else if (arg === "-t" || arg === "--template") {
73
+ args.template = argv[++i];
74
+ if (!args.template) fail(`${arg} requires a value`);
75
+ } else if (arg.startsWith("--template=")) {
76
+ args.template = arg.slice("--template=".length);
77
+ } else if (arg === "--package-manager") {
78
+ args.packageManager = argv[++i];
79
+ if (!args.packageManager) fail(`${arg} requires a value`);
80
+ } else if (arg.startsWith("--package-manager=")) {
81
+ args.packageManager = arg.slice("--package-manager=".length);
82
+ } else if (arg === "--js") args.lang = "js";
83
+ else if (arg === "--ts") args.lang = "ts";
84
+ else if (arg === "--overwrite") args.overwrite = true;
85
+ else if (arg.startsWith("-")) fail(`Unknown option: ${arg}`);
86
+ else if (args.dir === undefined) args.dir = arg;
87
+ else fail(`Unexpected argument: ${arg}`);
88
+ }
89
+ return args;
90
+ }
91
+
92
+ function fail(message) {
93
+ console.error(`create-rango: ${message}\n\nRun create-rango --help.`);
94
+ process.exit(1);
95
+ }
96
+
97
+ function bail(value, message = "Cancelled.") {
98
+ if (p.isCancel(value)) {
99
+ p.cancel(message);
100
+ process.exit(1);
101
+ }
102
+ return value;
103
+ }
104
+
105
+ // Names npm rejects outright (validate-npm-package-name blacklist).
106
+ const RESERVED_PACKAGE_NAMES = new Set(["node_modules", "favicon.ico"]);
107
+
108
+ function toValidPackageName(name) {
109
+ const valid = name
110
+ .trim()
111
+ .toLowerCase()
112
+ .replace(/\s+/g, "-")
113
+ .replace(/^[-._]+/, "")
114
+ .replace(/[^a-z0-9-~._]+/g, "-")
115
+ .slice(0, 214);
116
+ if (!valid || RESERVED_PACKAGE_NAMES.has(valid)) return "rango-app";
117
+ return valid;
118
+ }
119
+
120
+ // Cloudflare Worker names allow only [a-z0-9-] and cap at 54 chars for
121
+ // workers.dev, stricter than npm package names (which keep _ . ~).
122
+ function toValidWorkerName(name) {
123
+ const valid = name
124
+ .trim()
125
+ .toLowerCase()
126
+ .replace(/[^a-z0-9-]+/g, "-")
127
+ .replace(/-+/g, "-")
128
+ .replace(/^-+|-+$/g, "")
129
+ .slice(0, 54)
130
+ .replace(/-+$/, "");
131
+ return valid || "rango-app";
132
+ }
133
+
134
+ // Render a path for the copy-pasteable `cd` step: anchor relative paths with
135
+ // ./ (so a leading "-" cannot parse as an option and "~" cannot expand), then
136
+ // POSIX-quote anything outside the safe charset.
137
+ function shellPath(value) {
138
+ const anchored =
139
+ path.isAbsolute(value) || value.startsWith(".") ? value : `./${value}`;
140
+ if (/^[A-Za-z0-9._/-]+$/.test(anchored)) return anchored;
141
+ return `'${anchored.replaceAll("'", `'\\''`)}'`;
142
+ }
143
+
144
+ function isEmptyDir(dir) {
145
+ const entries = fs.readdirSync(dir);
146
+ return (
147
+ entries.length === 0 || (entries.length === 1 && entries[0] === ".git")
148
+ );
149
+ }
150
+
151
+ function detectPackageManager() {
152
+ const userAgent = process.env.npm_config_user_agent ?? "";
153
+ if (userAgent.startsWith("pnpm/")) return "pnpm";
154
+ return "npm";
155
+ }
156
+
157
+ // Local install/build artifacts that may exist in a template checkout (repo
158
+ // dev, or a user re-running into the same dir) and must never be scaffolded.
159
+ const SKIP_COPY = new Set([
160
+ "node_modules",
161
+ "dist",
162
+ ".wrangler",
163
+ ".vercel",
164
+ ".vite",
165
+ ".turbo",
166
+ ]);
167
+
168
+ // Local credential files (wrangler `.dev.vars`, dotenv) must never be copied
169
+ // into a scaffold either; mirrors scripts/sync-templates.mjs.
170
+ function shouldSkipCopy(basename) {
171
+ return (
172
+ SKIP_COPY.has(basename) ||
173
+ basename.startsWith(".env") ||
174
+ basename.startsWith(".dev.vars")
175
+ );
176
+ }
177
+
178
+ function copyTemplate(templateDir, targetDir) {
179
+ fs.cpSync(templateDir, targetDir, {
180
+ recursive: true,
181
+ filter: (src) => !shouldSkipCopy(path.basename(src)),
182
+ });
183
+ const gitignore = path.join(targetDir, "_gitignore");
184
+ if (fs.existsSync(gitignore)) {
185
+ fs.renameSync(gitignore, path.join(targetDir, ".gitignore"));
186
+ }
187
+ }
188
+
189
+ function patchJsonName(file, name) {
190
+ if (!fs.existsSync(file)) return;
191
+ const source = fs.readFileSync(file, "utf8");
192
+ JSON.parse(source);
193
+ const nameProperty = /("name"\s*:\s*)"(?:\\.|[^"\\])*"/;
194
+ if (!nameProperty.test(source)) {
195
+ throw new Error(`Cannot update name in ${file}`);
196
+ }
197
+ const next = source.replace(
198
+ nameProperty,
199
+ (_match, prefix) => `${prefix}${JSON.stringify(name)}`,
200
+ );
201
+ fs.writeFileSync(file, next);
202
+ }
203
+
204
+ async function main() {
205
+ const args = parseArgs(process.argv.slice(2));
206
+
207
+ if (args.help) {
208
+ console.log(HELP);
209
+ return;
210
+ }
211
+ if (args.version) {
212
+ const own = JSON.parse(
213
+ fs.readFileSync(path.join(__dirname, "package.json"), "utf8"),
214
+ );
215
+ console.log(own.version);
216
+ return;
217
+ }
218
+
219
+ p.intro("create-rango");
220
+
221
+ // engines says >=24; npm only warns on engine mismatch, so repeat it loudly
222
+ // for anyone who got here on an older Node (generated apps hard-require 24).
223
+ const nodeMajor = Number(process.versions.node.split(".")[0]);
224
+ if (nodeMajor < 24) {
225
+ p.log.warn(
226
+ `Node ${process.versions.node} detected — create-rango and the apps it generates require Node 24+. Continuing, but installing/running the generated app will fail on this Node.`,
227
+ );
228
+ }
229
+
230
+ let dir = args.dir;
231
+ if (dir === undefined) {
232
+ dir = bail(
233
+ await p.text({
234
+ message: "Where should the app be created?",
235
+ placeholder: "./rango-app",
236
+ defaultValue: "rango-app",
237
+ }),
238
+ );
239
+ }
240
+ const targetDir = path.resolve(process.cwd(), dir);
241
+ const packageName = toValidPackageName(path.basename(targetDir));
242
+
243
+ // Accept both `--template cloudflare --js` and `--template cloudflare-js`.
244
+ let template = args.template;
245
+ let lang = args.lang;
246
+ if (template?.endsWith("-js")) {
247
+ template = template.slice(0, -3);
248
+ lang ??= "js";
249
+ }
250
+ if (template && !TEMPLATES.some((t) => t.name === template)) {
251
+ fail(
252
+ `Unknown template "${template}". Available: ${TEMPLATES.map((t) => t.name).join(", ")}`,
253
+ );
254
+ }
255
+ if (!template) {
256
+ template = bail(
257
+ await p.select({
258
+ message: "Which template?",
259
+ options: TEMPLATES.map((t) => ({
260
+ value: t.name,
261
+ label: t.label,
262
+ hint: t.hint,
263
+ })),
264
+ }),
265
+ );
266
+ if (TEMPLATES.find((t) => t.name === template)?.jsFlavor) {
267
+ lang ??= bail(
268
+ await p.select({
269
+ message: "TypeScript or JavaScript?",
270
+ options: [
271
+ { value: "ts", label: "TypeScript" },
272
+ { value: "js", label: "JavaScript" },
273
+ ],
274
+ }),
275
+ );
276
+ }
277
+ }
278
+ lang ??= "ts";
279
+ if (lang === "js" && !TEMPLATES.find((t) => t.name === template)?.jsFlavor) {
280
+ const jsTemplates = TEMPLATES.filter((t) => t.jsFlavor).map((t) => t.name);
281
+ fail(
282
+ `the JavaScript flavor is only available for: ${jsTemplates.join(", ")}`,
283
+ );
284
+ }
285
+
286
+ if (args.packageManager && !PACKAGE_MANAGERS.includes(args.packageManager)) {
287
+ fail(
288
+ `Unknown package manager "${args.packageManager}". Available: ${PACKAGE_MANAGERS.join(", ")}`,
289
+ );
290
+ }
291
+ let packageManager = args.packageManager ?? detectPackageManager();
292
+ if (!args.packageManager && process.stdin.isTTY && process.stdout.isTTY) {
293
+ packageManager = bail(
294
+ await p.select({
295
+ message: "Which package manager?",
296
+ initialValue: packageManager,
297
+ options: PACKAGE_MANAGERS.map((value) => ({ value, label: value })),
298
+ }),
299
+ );
300
+ }
301
+
302
+ const templateName = lang === "js" ? `${template}-js` : template;
303
+ const templateDir = path.join(TEMPLATES_ROOT, templateName);
304
+ if (!fs.existsSync(templateDir)) {
305
+ fail(`Template directory missing: ${templateDir}`);
306
+ }
307
+
308
+ if (fs.existsSync(targetDir) && !isEmptyDir(targetDir)) {
309
+ if (args.overwrite) {
310
+ // Keep the directory itself (it may be the cwd); template files
311
+ // overwrite existing ones of the same name.
312
+ } else if (process.stdout.isTTY) {
313
+ const overwrite = bail(
314
+ await p.confirm({
315
+ message: `${path.relative(process.cwd(), targetDir) || "."} is not empty. Continue and overwrite matching files?`,
316
+ initialValue: false,
317
+ }),
318
+ );
319
+ if (!overwrite) {
320
+ p.cancel("Cancelled.");
321
+ process.exit(1);
322
+ }
323
+ } else {
324
+ fail(`${targetDir} is not empty (pass --overwrite to continue)`);
325
+ }
326
+ }
327
+
328
+ copyTemplate(templateDir, targetDir);
329
+ patchJsonName(path.join(targetDir, "package.json"), packageName);
330
+ patchJsonName(
331
+ path.join(targetDir, "wrangler.json"),
332
+ toValidWorkerName(path.basename(targetDir)),
333
+ );
334
+
335
+ const cd = path.relative(process.cwd(), targetDir);
336
+ const steps = [
337
+ ...(cd ? [`cd ${shellPath(cd)}`] : []),
338
+ `${packageManager} install`,
339
+ packageManager === "npm" ? "npm run dev" : "pnpm dev",
340
+ ];
341
+ p.note(steps.join("\n"), "Next steps");
342
+ p.outro(`Scaffolded ${templateName} template in ${cd || "."}`);
343
+ }
344
+
345
+ main().catch((err) => {
346
+ console.error(err);
347
+ process.exit(1);
348
+ });
package/package.json ADDED
@@ -0,0 +1,50 @@
1
+ {
2
+ "name": "create-rango",
3
+ "version": "0.1.0",
4
+ "description": "Scaffold a React Server Components app powered by @rangojs/router",
5
+ "keywords": [
6
+ "create",
7
+ "rango",
8
+ "react-server-components",
9
+ "rsc",
10
+ "scaffold",
11
+ "vite"
12
+ ],
13
+ "homepage": "https://github.com/rangojs/templates#readme",
14
+ "bugs": {
15
+ "url": "https://github.com/rangojs/templates/issues"
16
+ },
17
+ "license": "MIT",
18
+ "author": "Ivo Todorov",
19
+ "repository": {
20
+ "type": "git",
21
+ "url": "git+https://github.com/rangojs/templates.git",
22
+ "directory": "packages/create-rango"
23
+ },
24
+ "bin": {
25
+ "create-rango": "index.js"
26
+ },
27
+ "files": [
28
+ "index.js",
29
+ "templates"
30
+ ],
31
+ "type": "module",
32
+ "publishConfig": {
33
+ "access": "public",
34
+ "provenance": true
35
+ },
36
+ "scripts": {
37
+ "prepack": "node scripts/sync-templates.mjs",
38
+ "postpack": "node scripts/sync-templates.mjs --clean",
39
+ "prepublishOnly": "npm test",
40
+ "test": "node test/smoke.mjs && node test/pack-smoke.mjs",
41
+ "test:smoke": "node test/smoke.mjs",
42
+ "test:pack": "node test/pack-smoke.mjs"
43
+ },
44
+ "dependencies": {
45
+ "@clack/prompts": "^1.7.0"
46
+ },
47
+ "engines": {
48
+ "node": ">=24.0.0"
49
+ }
50
+ }
@@ -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/"]
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/"]
6
+ }
@@ -0,0 +1,35 @@
1
+ # Rango app
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), Vite, and Tailwind CSS.
4
+
5
+ ## Commands
6
+
7
+ ```sh
8
+ npm run dev # start the dev server
9
+ npm run build # production build to dist/
10
+ npm run start # run the production server (server.mjs)
11
+ npm run preview # serve the production build locally (Vite preview, local-only)
12
+ npm run generate # regenerate committed route maps
13
+ npm test # run unit tests
14
+ npm run typecheck # tsc --noEmit
15
+ npm run lint # oxlint
16
+ npm run format # oxfmt check
17
+ npm run check # typecheck + tests + lint + format
18
+ ```
19
+
20
+ Requires Node.js 24 or newer.
21
+
22
+ ## Deploying
23
+
24
+ `npm run build && npm run start` is the production path: `server.mjs` serves fingerprinted assets with immutable cache headers, keeps public files revalidatable, and streams everything else through the router's fetch handler. Set `PORT` to change the port. `vite preview` is a local convenience, not a production server.
25
+
26
+ ## Project layout
27
+
28
+ - `server.mjs` — the production server (`npm run start`): static assets via sirv, everything else via the router.
29
+ - `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.
30
+ - `src/components/Document.tsx` — the HTML shell (client component). Tailwind is wired here via `styles.css?url`.
31
+ - `src/components/pages/` — route handlers. Each is a server function receiving a `HandlerContext` (params, meta, headers, …).
32
+ - `src/actions/` — `"use server"` functions callable from client components.
33
+ - `test/router.test.ts` — route-map drift test using `@rangojs/router/testing`.
34
+ - `src/router.named-routes.gen.ts` — generated global route names for `Handler<"name">` and `ctx.reverse()`; commit it.
35
+ - `src/router.gen.ts` — generated local route map used by `useReverse()` in client components; run `npm run generate` after route changes and commit it.
@@ -0,0 +1,4 @@
1
+ node_modules
2
+ dist
3
+ .env*
4
+ *.local
@@ -0,0 +1,44 @@
1
+ {
2
+ "name": "rango-template-basic",
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
+ "start": "node server.mjs",
12
+ "preview": "vite preview",
13
+ "generate": "rango generate src/",
14
+ "test": "vitest run",
15
+ "typecheck": "tsc --noEmit",
16
+ "lint": "oxlint --deny-warnings .",
17
+ "format": "oxfmt --check .",
18
+ "format:fix": "oxfmt .",
19
+ "check": "tsc --noEmit && vitest run && oxlint --deny-warnings . && oxfmt --check ."
20
+ },
21
+ "dependencies": {
22
+ "@rangojs/router": "^0.4.0",
23
+ "react": "^19.2.6",
24
+ "react-dom": "^19.2.6",
25
+ "sirv": "^3.0.0",
26
+ "srvx": "^0.11.22"
27
+ },
28
+ "devDependencies": {
29
+ "@tailwindcss/vite": "^4.3.1",
30
+ "@types/node": "^24.10.0",
31
+ "@types/react": "^19.2.7",
32
+ "@types/react-dom": "^19.2.3",
33
+ "@vitejs/plugin-react": "^6.0.0",
34
+ "oxfmt": "^0.58.0",
35
+ "oxlint": "^1.73.0",
36
+ "tailwindcss": "^4.3.1",
37
+ "typescript": "^5.9.3",
38
+ "vite": "^8.0.16",
39
+ "vitest": "^4.1.10"
40
+ },
41
+ "engines": {
42
+ "node": ">=24.0.0"
43
+ }
44
+ }
@@ -0,0 +1,46 @@
1
+ // Production server: serves the vite build from dist/.
2
+ // Build first (`npm run build`), then `npm run start`.
3
+ import { createServer } from "node:http";
4
+ import sirv from "sirv";
5
+ import { toNodeHandler } from "srvx/node";
6
+ import handler from "./dist/rsc/index.js";
7
+
8
+ const port = Number(process.env.PORT ?? 3000);
9
+
10
+ const serveStatic = sirv("dist/client");
11
+ const serveHashedAssets = sirv("dist/client", {
12
+ immutable: true,
13
+ maxAge: 31536000,
14
+ });
15
+
16
+ // srvx bridges the router's Web fetch handler onto Node's (req, res), piping
17
+ // streamed responses through.
18
+ const serveApp = toNodeHandler((request) =>
19
+ handler(request, { env: process.env }),
20
+ );
21
+
22
+ function isFingerprintedAsset(url) {
23
+ const pathname = new URL(url, "http://localhost").pathname;
24
+ return /^\/assets\/.+-[A-Za-z0-9_-]{8,}\.[A-Za-z0-9]+$/.test(pathname);
25
+ }
26
+
27
+ const server = createServer((req, res) => {
28
+ // Only Vite fingerprinted files are immutable. A user may also place an
29
+ // unhashed public file under public/assets/, which must remain revalidatable.
30
+ const serveAssets = isFingerprintedAsset(req.url ?? "/")
31
+ ? serveHashedAssets
32
+ : serveStatic;
33
+ serveAssets(req, res, () => serveApp(req, res));
34
+ });
35
+ server.listen(port, () => {
36
+ console.log(`Listening on http://localhost:${server.address().port}`);
37
+ });
38
+
39
+ function shutdown() {
40
+ const forceClose = setTimeout(() => server.closeAllConnections(), 10_000);
41
+ forceClose.unref();
42
+ server.close(() => clearTimeout(forceClose));
43
+ }
44
+
45
+ process.once("SIGTERM", shutdown);
46
+ process.once("SIGINT", shutdown);
@@ -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
+ }