@rangojs/router 0.0.0-experimental.10

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 (172) hide show
  1. package/CLAUDE.md +43 -0
  2. package/README.md +19 -0
  3. package/dist/bin/rango.js +227 -0
  4. package/dist/vite/index.js +3039 -0
  5. package/package.json +171 -0
  6. package/skills/caching/SKILL.md +191 -0
  7. package/skills/debug-manifest/SKILL.md +108 -0
  8. package/skills/document-cache/SKILL.md +180 -0
  9. package/skills/fonts/SKILL.md +165 -0
  10. package/skills/hooks/SKILL.md +442 -0
  11. package/skills/intercept/SKILL.md +190 -0
  12. package/skills/layout/SKILL.md +213 -0
  13. package/skills/links/SKILL.md +180 -0
  14. package/skills/loader/SKILL.md +246 -0
  15. package/skills/middleware/SKILL.md +202 -0
  16. package/skills/mime-routes/SKILL.md +124 -0
  17. package/skills/parallel/SKILL.md +228 -0
  18. package/skills/prerender/SKILL.md +283 -0
  19. package/skills/rango/SKILL.md +54 -0
  20. package/skills/response-routes/SKILL.md +358 -0
  21. package/skills/route/SKILL.md +173 -0
  22. package/skills/router-setup/SKILL.md +346 -0
  23. package/skills/tailwind/SKILL.md +129 -0
  24. package/skills/theme/SKILL.md +78 -0
  25. package/skills/typesafety/SKILL.md +394 -0
  26. package/src/__internal.ts +175 -0
  27. package/src/bin/rango.ts +24 -0
  28. package/src/browser/event-controller.ts +876 -0
  29. package/src/browser/index.ts +18 -0
  30. package/src/browser/link-interceptor.ts +121 -0
  31. package/src/browser/lru-cache.ts +69 -0
  32. package/src/browser/merge-segment-loaders.ts +126 -0
  33. package/src/browser/navigation-bridge.ts +913 -0
  34. package/src/browser/navigation-client.ts +165 -0
  35. package/src/browser/navigation-store.ts +823 -0
  36. package/src/browser/partial-update.ts +600 -0
  37. package/src/browser/react/Link.tsx +248 -0
  38. package/src/browser/react/NavigationProvider.tsx +346 -0
  39. package/src/browser/react/ScrollRestoration.tsx +94 -0
  40. package/src/browser/react/context.ts +53 -0
  41. package/src/browser/react/index.ts +52 -0
  42. package/src/browser/react/location-state-shared.ts +120 -0
  43. package/src/browser/react/location-state.ts +62 -0
  44. package/src/browser/react/mount-context.ts +32 -0
  45. package/src/browser/react/use-action.ts +240 -0
  46. package/src/browser/react/use-client-cache.ts +56 -0
  47. package/src/browser/react/use-handle.ts +203 -0
  48. package/src/browser/react/use-href.tsx +40 -0
  49. package/src/browser/react/use-link-status.ts +134 -0
  50. package/src/browser/react/use-mount.ts +31 -0
  51. package/src/browser/react/use-navigation.ts +140 -0
  52. package/src/browser/react/use-segments.ts +188 -0
  53. package/src/browser/request-controller.ts +164 -0
  54. package/src/browser/rsc-router.tsx +352 -0
  55. package/src/browser/scroll-restoration.ts +324 -0
  56. package/src/browser/segment-structure-assert.ts +67 -0
  57. package/src/browser/server-action-bridge.ts +762 -0
  58. package/src/browser/shallow.ts +35 -0
  59. package/src/browser/types.ts +478 -0
  60. package/src/build/generate-manifest.ts +377 -0
  61. package/src/build/generate-route-types.ts +828 -0
  62. package/src/build/index.ts +36 -0
  63. package/src/build/route-trie.ts +239 -0
  64. package/src/cache/cache-scope.ts +563 -0
  65. package/src/cache/cf/cf-cache-store.ts +428 -0
  66. package/src/cache/cf/index.ts +19 -0
  67. package/src/cache/document-cache.ts +340 -0
  68. package/src/cache/index.ts +58 -0
  69. package/src/cache/memory-segment-store.ts +150 -0
  70. package/src/cache/memory-store.ts +253 -0
  71. package/src/cache/types.ts +392 -0
  72. package/src/client.rsc.tsx +83 -0
  73. package/src/client.tsx +643 -0
  74. package/src/component-utils.ts +76 -0
  75. package/src/components/DefaultDocument.tsx +23 -0
  76. package/src/debug.ts +233 -0
  77. package/src/default-error-boundary.tsx +88 -0
  78. package/src/deps/browser.ts +8 -0
  79. package/src/deps/html-stream-client.ts +2 -0
  80. package/src/deps/html-stream-server.ts +2 -0
  81. package/src/deps/rsc.ts +10 -0
  82. package/src/deps/ssr.ts +2 -0
  83. package/src/errors.ts +295 -0
  84. package/src/handle.ts +130 -0
  85. package/src/handles/MetaTags.tsx +193 -0
  86. package/src/handles/index.ts +6 -0
  87. package/src/handles/meta.ts +247 -0
  88. package/src/host/cookie-handler.ts +159 -0
  89. package/src/host/errors.ts +97 -0
  90. package/src/host/index.ts +56 -0
  91. package/src/host/pattern-matcher.ts +214 -0
  92. package/src/host/router.ts +330 -0
  93. package/src/host/testing.ts +79 -0
  94. package/src/host/types.ts +138 -0
  95. package/src/host/utils.ts +25 -0
  96. package/src/href-client.ts +202 -0
  97. package/src/href-context.ts +33 -0
  98. package/src/index.rsc.ts +121 -0
  99. package/src/index.ts +165 -0
  100. package/src/loader.rsc.ts +207 -0
  101. package/src/loader.ts +47 -0
  102. package/src/network-error-thrower.tsx +21 -0
  103. package/src/outlet-context.ts +15 -0
  104. package/src/prerender/param-hash.ts +35 -0
  105. package/src/prerender/store.ts +40 -0
  106. package/src/prerender.ts +156 -0
  107. package/src/reverse.ts +267 -0
  108. package/src/root-error-boundary.tsx +277 -0
  109. package/src/route-content-wrapper.tsx +193 -0
  110. package/src/route-definition.ts +1431 -0
  111. package/src/route-map-builder.ts +242 -0
  112. package/src/route-types.ts +220 -0
  113. package/src/router/error-handling.ts +287 -0
  114. package/src/router/handler-context.ts +158 -0
  115. package/src/router/intercept-resolution.ts +387 -0
  116. package/src/router/loader-resolution.ts +327 -0
  117. package/src/router/manifest.ts +216 -0
  118. package/src/router/match-api.ts +621 -0
  119. package/src/router/match-context.ts +264 -0
  120. package/src/router/match-middleware/background-revalidation.ts +236 -0
  121. package/src/router/match-middleware/cache-lookup.ts +382 -0
  122. package/src/router/match-middleware/cache-store.ts +276 -0
  123. package/src/router/match-middleware/index.ts +81 -0
  124. package/src/router/match-middleware/intercept-resolution.ts +281 -0
  125. package/src/router/match-middleware/segment-resolution.ts +184 -0
  126. package/src/router/match-pipelines.ts +214 -0
  127. package/src/router/match-result.ts +213 -0
  128. package/src/router/metrics.ts +62 -0
  129. package/src/router/middleware.ts +791 -0
  130. package/src/router/pattern-matching.ts +407 -0
  131. package/src/router/revalidation.ts +190 -0
  132. package/src/router/router-context.ts +301 -0
  133. package/src/router/segment-resolution.ts +1315 -0
  134. package/src/router/trie-matching.ts +172 -0
  135. package/src/router/types.ts +163 -0
  136. package/src/router.gen.ts +6 -0
  137. package/src/router.ts +2423 -0
  138. package/src/rsc/handler.ts +1443 -0
  139. package/src/rsc/helpers.ts +64 -0
  140. package/src/rsc/index.ts +56 -0
  141. package/src/rsc/nonce.ts +18 -0
  142. package/src/rsc/types.ts +236 -0
  143. package/src/segment-system.tsx +442 -0
  144. package/src/server/context.ts +466 -0
  145. package/src/server/handle-store.ts +229 -0
  146. package/src/server/loader-registry.ts +174 -0
  147. package/src/server/request-context.ts +554 -0
  148. package/src/server/root-layout.tsx +10 -0
  149. package/src/server/tsconfig.json +14 -0
  150. package/src/server.ts +171 -0
  151. package/src/ssr/index.tsx +296 -0
  152. package/src/theme/ThemeProvider.tsx +291 -0
  153. package/src/theme/ThemeScript.tsx +61 -0
  154. package/src/theme/constants.ts +59 -0
  155. package/src/theme/index.ts +58 -0
  156. package/src/theme/theme-context.ts +70 -0
  157. package/src/theme/theme-script.ts +152 -0
  158. package/src/theme/types.ts +182 -0
  159. package/src/theme/use-theme.ts +44 -0
  160. package/src/types.ts +1757 -0
  161. package/src/urls.gen.ts +8 -0
  162. package/src/urls.ts +1282 -0
  163. package/src/use-loader.tsx +346 -0
  164. package/src/vite/expose-action-id.ts +344 -0
  165. package/src/vite/expose-handle-id.ts +209 -0
  166. package/src/vite/expose-loader-id.ts +426 -0
  167. package/src/vite/expose-location-state-id.ts +177 -0
  168. package/src/vite/expose-prerender-handler-id.ts +429 -0
  169. package/src/vite/index.ts +2068 -0
  170. package/src/vite/package-resolution.ts +125 -0
  171. package/src/vite/version.d.ts +12 -0
  172. package/src/vite/virtual-entries.ts +114 -0
@@ -0,0 +1,177 @@
1
+ import type { Plugin, ResolvedConfig } from "vite";
2
+ import MagicString from "magic-string";
3
+ import path from "node:path";
4
+ import crypto from "node:crypto";
5
+
6
+ /**
7
+ * Normalize path to forward slashes
8
+ */
9
+ function normalizePath(p: string): string {
10
+ return p.split(path.sep).join("/");
11
+ }
12
+
13
+ /**
14
+ * Generate a short hash for a location state key
15
+ * Uses first 8 chars of SHA-256 hash for uniqueness while keeping keys short
16
+ * Appends export name for easier debugging: "abc123#ProductState"
17
+ */
18
+ function hashLocationStateKey(filePath: string, exportName: string): string {
19
+ const input = `${filePath}#${exportName}`;
20
+ const hash = crypto.createHash("sha256").update(input).digest("hex");
21
+ return `${hash.slice(0, 8)}#${exportName}`;
22
+ }
23
+
24
+ /**
25
+ * Check if file imports createLocationState from rsc-router
26
+ */
27
+ function hasCreateLocationStateImport(code: string): boolean {
28
+ // Match: import { createLocationState } from "@rangojs/router" or "@rangojs/router/client"
29
+ const pattern =
30
+ /import\s*\{[^}]*\bcreateLocationState\b[^}]*\}\s*from\s*["']@rangojs\/router(?:\/[^"']+)?["']/;
31
+ return pattern.test(code);
32
+ }
33
+
34
+ /**
35
+ * Transform export const X = createLocationState<...>() patterns to inject key
36
+ *
37
+ * The key is injected as the first parameter if not present:
38
+ * - createLocationState() -> createLocationState("id")
39
+ * - createLocationState<T>() -> createLocationState<T>("id")
40
+ */
41
+ function transformLocationStateExports(
42
+ code: string,
43
+ filePath: string,
44
+ sourceId?: string,
45
+ isBuild: boolean = false
46
+ ): { code: string; map: ReturnType<MagicString["generateMap"]> } | null {
47
+ // Quick bail-out
48
+ if (!code.includes("createLocationState")) {
49
+ return null;
50
+ }
51
+
52
+ // Must have direct import from rsc-router
53
+ if (!hasCreateLocationStateImport(code)) {
54
+ return null;
55
+ }
56
+
57
+ // Match: export const X = createLocationState<...>(
58
+ // Captures the export name (X)
59
+ const pattern = /export\s+const\s+(\w+)\s*=\s*createLocationState\s*(?:<[^>]*>)?\s*\(/g;
60
+
61
+ const s = new MagicString(code);
62
+ let hasChanges = false;
63
+ let match: RegExpExecArray | null;
64
+
65
+ while ((match = pattern.exec(code)) !== null) {
66
+ const exportName = match[1];
67
+ const matchEnd = match.index + match[0].length;
68
+
69
+ // Find the end of the createLocationState(...) call
70
+ let parenDepth = 1;
71
+ let i = matchEnd;
72
+ while (i < code.length && parenDepth > 0) {
73
+ if (code[i] === "(") parenDepth++;
74
+ if (code[i] === ")") parenDepth--;
75
+ i++;
76
+ }
77
+
78
+ // i now points just after the closing )
79
+ const closeParenPos = i - 1;
80
+
81
+ // Check if there are any arguments (content between open and close paren)
82
+ const content = code.slice(matchEnd, closeParenPos).trim();
83
+ const hasArgs = content.length > 0;
84
+
85
+ // Find the semicolon or end of statement
86
+ let statementEnd = i;
87
+ while (statementEnd < code.length && /\s/.test(code[statementEnd])) {
88
+ statementEnd++;
89
+ }
90
+ if (code[statementEnd] === ";") {
91
+ statementEnd++;
92
+ }
93
+
94
+ // Generate key: hashed in production, readable in dev
95
+ const stateKey = isBuild
96
+ ? hashLocationStateKey(filePath, exportName)
97
+ : `${filePath}#${exportName}`;
98
+
99
+ // Inject key as the first (and only) parameter
100
+ // createLocationState() -> createLocationState("id")
101
+ if (!hasArgs) {
102
+ s.appendLeft(closeParenPos, `"${stateKey}"`);
103
+ } else {
104
+ // Already has a key, skip (shouldn't happen with new API, but be safe)
105
+ continue;
106
+ }
107
+
108
+ // Also set __rsc_ls_key property for verification
109
+ const propInjection = `\n${exportName}.__rsc_ls_key = "__rsc_ls_${stateKey}";`;
110
+ s.appendRight(statementEnd, propInjection);
111
+ hasChanges = true;
112
+ }
113
+
114
+ if (!hasChanges) {
115
+ return null;
116
+ }
117
+
118
+ return {
119
+ code: s.toString(),
120
+ map: s.generateMap({ source: sourceId, includeContent: true }),
121
+ };
122
+ }
123
+
124
+ /**
125
+ * Vite plugin that exposes location state keys on createLocationState calls.
126
+ *
127
+ * When users create location states with createLocationState(), this plugin:
128
+ * 1. Injects an auto-generated key as the first parameter
129
+ * 2. Sets __rsc_ls_key property for verification
130
+ *
131
+ * This allows location states to be created without explicit keys:
132
+ * - Before: export const ProductState = createLocationState<Product>("product")
133
+ * - After: export const ProductState = createLocationState<Product>()
134
+ *
135
+ * The key is auto-generated from file path + export name.
136
+ *
137
+ * Requirements:
138
+ * - Must use direct import: import { createLocationState } from "@rangojs/router"
139
+ * - Must use named export: export const MyState = createLocationState(...)
140
+ */
141
+ export function exposeLocationStateId(): Plugin {
142
+ let config: ResolvedConfig;
143
+ let isBuild = false;
144
+
145
+ return {
146
+ name: "@rangojs/router:expose-location-state-id",
147
+ enforce: "post",
148
+
149
+ configResolved(resolvedConfig) {
150
+ config = resolvedConfig;
151
+ isBuild = config.command === "build";
152
+ },
153
+
154
+ transform(code, id) {
155
+ // Skip node_modules
156
+ if (id.includes("/node_modules/")) {
157
+ return;
158
+ }
159
+
160
+ // Quick bail-out
161
+ if (!code.includes("createLocationState")) {
162
+ return;
163
+ }
164
+
165
+ // Must have direct import from rsc-router
166
+ if (!hasCreateLocationStateImport(code)) {
167
+ return;
168
+ }
169
+
170
+ // Get relative path for the key
171
+ const relativePath = normalizePath(path.relative(config.root, id));
172
+
173
+ // Transform: inject key
174
+ return transformLocationStateExports(code, relativePath, id, isBuild);
175
+ },
176
+ };
177
+ }
@@ -0,0 +1,429 @@
1
+ import type { Plugin, ResolvedConfig } from "vite";
2
+ import MagicString from "magic-string";
3
+ import path from "node:path";
4
+ import crypto from "node:crypto";
5
+
6
+ /**
7
+ * Normalize path to forward slashes
8
+ */
9
+ function normalizePath(p: string): string {
10
+ return p.split(path.sep).join("/");
11
+ }
12
+
13
+ /**
14
+ * Generate a short hash for a prerender handler ID.
15
+ * Uses first 8 chars of SHA-256 hash for uniqueness while keeping IDs short.
16
+ * Appends export name for easier debugging: "abc123#DocsPage"
17
+ */
18
+ function hashPrerenderHandlerId(filePath: string, exportName: string): string {
19
+ const input = `${filePath}#${exportName}`;
20
+ const hash = crypto.createHash("sha256").update(input).digest("hex");
21
+ return `${hash.slice(0, 8)}#${exportName}`;
22
+ }
23
+
24
+ /**
25
+ * Check if file imports createPrerenderHandler from @rangojs/router
26
+ */
27
+ function hasCreatePrerenderHandlerImport(code: string): boolean {
28
+ const pattern =
29
+ /import\s*\{[^}]*\bcreatePrerenderHandler\b[^}]*\}\s*from\s*["']@rangojs\/router(?:\/[^"']+)?["']/;
30
+ return pattern.test(code);
31
+ }
32
+
33
+ /**
34
+ * Skip past a string literal, template literal, or comment starting at pos.
35
+ * Returns the index after the closing delimiter, or pos if not at a
36
+ * string/comment start. Handles escape sequences and nested ${} in templates.
37
+ */
38
+ function skipStringOrComment(code: string, pos: number): number {
39
+ const ch = code[pos];
40
+
41
+ if (ch === '"' || ch === "'") {
42
+ for (let j = pos + 1; j < code.length; j++) {
43
+ if (code[j] === "\\") { j++; continue; }
44
+ if (code[j] === ch) return j + 1;
45
+ }
46
+ return code.length;
47
+ }
48
+
49
+ if (ch === "`") {
50
+ let j = pos + 1;
51
+ while (j < code.length) {
52
+ if (code[j] === "\\") { j += 2; continue; }
53
+ if (code[j] === "`") return j + 1;
54
+ if (code[j] === "$" && j + 1 < code.length && code[j + 1] === "{") {
55
+ j += 2;
56
+ let braceDepth = 1;
57
+ while (j < code.length && braceDepth > 0) {
58
+ const inner = skipStringOrComment(code, j);
59
+ if (inner > j) { j = inner; continue; }
60
+ if (code[j] === "{") braceDepth++;
61
+ else if (code[j] === "}") braceDepth--;
62
+ if (braceDepth > 0) j++;
63
+ }
64
+ if (braceDepth === 0) j++;
65
+ continue;
66
+ }
67
+ j++;
68
+ }
69
+ return j;
70
+ }
71
+
72
+ if (ch === "/" && pos + 1 < code.length) {
73
+ if (code[pos + 1] === "/") {
74
+ const eol = code.indexOf("\n", pos + 2);
75
+ return eol === -1 ? code.length : eol + 1;
76
+ }
77
+ if (code[pos + 1] === "*") {
78
+ const end = code.indexOf("*/", pos + 2);
79
+ return end === -1 ? code.length : end + 2;
80
+ }
81
+ }
82
+
83
+ return pos;
84
+ }
85
+
86
+ /**
87
+ * Find the matching closing paren starting after an already-opened paren.
88
+ * Skips strings, template literals, and comments so parens inside them
89
+ * don't affect depth tracking. Returns the index after the closing paren.
90
+ */
91
+ function findMatchingParen(code: string, startPos: number): number {
92
+ let depth = 1;
93
+ let i = startPos;
94
+ while (i < code.length && depth > 0) {
95
+ const skipped = skipStringOrComment(code, i);
96
+ if (skipped > i) { i = skipped; continue; }
97
+ if (code[i] === "(") depth++;
98
+ if (code[i] === ")") depth--;
99
+ i++;
100
+ }
101
+ return i;
102
+ }
103
+
104
+ /**
105
+ * Count the number of top-level arguments in a function call.
106
+ * Skips nested parens, brackets, braces, strings, and comments.
107
+ */
108
+ function countArgs(code: string, startPos: number, endPos: number): number {
109
+ let depth = 0;
110
+ let argCount = 0;
111
+ let hasContent = false;
112
+ let i = startPos;
113
+
114
+ while (i < endPos) {
115
+ const skipped = skipStringOrComment(code, i);
116
+ if (skipped > i) { hasContent = true; i = skipped; continue; }
117
+
118
+ const char = code[i];
119
+ if (char === "(" || char === "[" || char === "{") {
120
+ depth++;
121
+ hasContent = true;
122
+ } else if (char === ")" || char === "]" || char === "}") {
123
+ depth--;
124
+ } else if (char === "," && depth === 0) {
125
+ argCount++;
126
+ } else if (!/\s/.test(char)) {
127
+ hasContent = true;
128
+ }
129
+ i++;
130
+ }
131
+
132
+ return hasContent ? argCount + 1 : 0;
133
+ }
134
+
135
+ /**
136
+ * Transform export const X = createPrerenderHandler(...) patterns to inject $$id.
137
+ *
138
+ * Overload shapes:
139
+ * 1 arg (handler) -> inject undefined, "id" (pad for options + id)
140
+ * 2 args (getParams+handler OR handler+options) -> inject , "id"
141
+ * 3 args (getParams+handler+options) -> inject , "id"
142
+ *
143
+ * The __injectedId is always the LAST parameter.
144
+ */
145
+ function transformPrerenderHandlerExports(
146
+ code: string,
147
+ filePath: string,
148
+ sourceId?: string,
149
+ isBuild: boolean = false,
150
+ ): { code: string; map: ReturnType<MagicString["generateMap"]> } | null {
151
+ if (!code.includes("createPrerenderHandler")) {
152
+ return null;
153
+ }
154
+
155
+ if (!hasCreatePrerenderHandlerImport(code)) {
156
+ return null;
157
+ }
158
+
159
+ // Match: export const X = createPrerenderHandler<...>(
160
+ const pattern =
161
+ /export\s+const\s+(\w+)\s*=\s*createPrerenderHandler\s*(?:<[^>]*>)?\s*\(/g;
162
+
163
+ const s = new MagicString(code);
164
+ let hasChanges = false;
165
+ let match: RegExpExecArray | null;
166
+
167
+ while ((match = pattern.exec(code)) !== null) {
168
+ const exportName = match[1];
169
+ const matchEnd = match.index + match[0].length;
170
+
171
+ // Find the matching closing paren (string/comment aware)
172
+ const afterClose = findMatchingParen(code, matchEnd);
173
+ const closeParenPos = afterClose - 1;
174
+ const argCount = countArgs(code, matchEnd, closeParenPos);
175
+
176
+ // Find statement end (after ; or whitespace)
177
+ let statementEnd = afterClose;
178
+ while (statementEnd < code.length && /\s/.test(code[statementEnd])) {
179
+ statementEnd++;
180
+ }
181
+ if (code[statementEnd] === ";") {
182
+ statementEnd++;
183
+ }
184
+
185
+ const handlerId = isBuild
186
+ ? hashPrerenderHandlerId(filePath, exportName)
187
+ : `${filePath}#${exportName}`;
188
+
189
+ // Inject $$id as the last parameter.
190
+ // createPrerenderHandler(handler) -> createPrerenderHandler(handler, undefined, "id")
191
+ // createPrerenderHandler(handler, opts) -> createPrerenderHandler(handler, opts, "id")
192
+ // createPrerenderHandler(getP, handler) -> createPrerenderHandler(getP, handler, undefined, "id")
193
+ // createPrerenderHandler(getP, handler, opts) -> createPrerenderHandler(getP, handler, opts, "id")
194
+ //
195
+ // The runtime implementation accepts __injectedId as:
196
+ // Overload 1 (1 fn): 3rd param (after handler, options)
197
+ // Overload 2 (2 fn): 4th param (after getParams, handler, options)
198
+ //
199
+ // We cannot statically distinguish between (handler, options) and (getParams, handler)
200
+ // when there are 2 args. However the runtime resolves this by checking typeof of the
201
+ // second arg. The __injectedId is always a string, so it can appear in either the
202
+ // options or id slot — the runtime handles both via typeof checks.
203
+ let paramInjection: string;
204
+ if (argCount === 0) {
205
+ paramInjection = `undefined, "${handlerId}"`;
206
+ } else if (argCount === 1) {
207
+ // 1 arg (handler only): need to pad for options slot
208
+ paramInjection = `, undefined, "${handlerId}"`;
209
+ } else {
210
+ // 2+ args: just append id
211
+ paramInjection = `, "${handlerId}"`;
212
+ }
213
+ s.appendLeft(closeParenPos, paramInjection);
214
+
215
+ // Set $$id property for external access
216
+ const propInjection = `\n${exportName}.$$id = "${handlerId}";`;
217
+ s.appendRight(statementEnd, propInjection);
218
+ hasChanges = true;
219
+ }
220
+
221
+ if (!hasChanges) {
222
+ return null;
223
+ }
224
+
225
+ return {
226
+ code: s.toString(),
227
+ map: s.generateMap({ source: sourceId, includeContent: true, hires: "boundary" }),
228
+ };
229
+ }
230
+
231
+ /**
232
+ * Replace the entire file with lightweight stubs when ALL non-type exports are
233
+ * createPrerenderHandler calls. This is the most aggressive eviction — all imports,
234
+ * module-level data, and handler bodies are removed from non-RSC bundles.
235
+ *
236
+ * Returns null for files with mixed exports (non-handler value exports),
237
+ * falling back to per-expression replacement.
238
+ */
239
+ function generateWholeFileHandlerStubs(
240
+ code: string,
241
+ filePath: string,
242
+ isBuild: boolean,
243
+ ): { code: string; map: null } | null {
244
+ const handlerPattern =
245
+ /export\s+const\s+(\w+)\s*=\s*createPrerenderHandler\s*(?:<[^>]*>)?\s*\(/g;
246
+ const handlers: string[] = [];
247
+ let match: RegExpExecArray | null;
248
+
249
+ while ((match = handlerPattern.exec(code)) !== null) {
250
+ handlers.push(match[1]);
251
+ }
252
+
253
+ if (handlers.length === 0) return null;
254
+
255
+ // Bail out if the file has re-exports or destructured exports that
256
+ // the declaration pattern below wouldn't catch. Replacing the whole
257
+ // file would silently drop these exports.
258
+ if (/export\s*\{/.test(code) || /export\s*\*/.test(code)) {
259
+ return null;
260
+ }
261
+
262
+ // Check that every non-type export declaration is a createPrerenderHandler call.
263
+ const allExports =
264
+ /export\s+(const|let|var|function|class|default)\s+(\w+)/g;
265
+ let exportMatch: RegExpExecArray | null;
266
+
267
+ while ((exportMatch = allExports.exec(code)) !== null) {
268
+ const name = exportMatch[2];
269
+ if (!handlers.includes(name)) {
270
+ // Mixed exports — can't replace the whole file
271
+ return null;
272
+ }
273
+ }
274
+
275
+ const stubs = handlers.map((name) => {
276
+ const handlerId = isBuild
277
+ ? hashPrerenderHandlerId(filePath, name)
278
+ : `${filePath}#${name}`;
279
+ return `export const ${name} = { __brand: "prerenderHandler", $$id: "${handlerId}" };`;
280
+ });
281
+
282
+ return { code: stubs.join("\n") + "\n", map: null };
283
+ }
284
+
285
+ /**
286
+ * Replace createPrerenderHandler(...) call expressions with lightweight stub objects
287
+ * in non-RSC environments. Other exports, imports, and module-level code remain
288
+ * untouched — only the call expression is replaced.
289
+ *
290
+ * This prevents handler rendering code and build-only dependencies from shipping
291
+ * to client/SSR bundles where handlers never execute.
292
+ */
293
+ function generatePrerenderHandlerStubs(
294
+ code: string,
295
+ filePath: string,
296
+ sourceId?: string,
297
+ isBuild: boolean = false,
298
+ ): { code: string; map: ReturnType<MagicString["generateMap"]> } | null {
299
+ // Match: export const X = createPrerenderHandler<...>(
300
+ const pattern =
301
+ /export\s+const\s+(\w+)\s*=\s*(createPrerenderHandler\s*(?:<[^>]*>)?\s*\()/g;
302
+
303
+ const s = new MagicString(code);
304
+ let hasChanges = false;
305
+ let match: RegExpExecArray | null;
306
+
307
+ while ((match = pattern.exec(code)) !== null) {
308
+ const exportName = match[1];
309
+ // callStart points to 'c' in 'createPrerenderHandler'
310
+ const callStart = match.index + match[0].length - match[2].length;
311
+
312
+ // Find the matching closing paren (string/comment aware)
313
+ const openParenPos = match.index + match[0].length;
314
+ const afterCloseParen = findMatchingParen(code, openParenPos);
315
+
316
+ const handlerId = isBuild
317
+ ? hashPrerenderHandlerId(filePath, exportName)
318
+ : `${filePath}#${exportName}`;
319
+
320
+ // Replace createPrerenderHandler<...>(...) with stub object
321
+ s.overwrite(
322
+ callStart,
323
+ afterCloseParen,
324
+ `{ __brand: "prerenderHandler", $$id: "${handlerId}" }`,
325
+ );
326
+ hasChanges = true;
327
+ }
328
+
329
+ if (!hasChanges) return null;
330
+
331
+ return {
332
+ code: s.toString(),
333
+ map: s.generateMap({ source: sourceId, includeContent: true, hires: "boundary" }),
334
+ };
335
+ }
336
+
337
+ /**
338
+ * Plugin API type for expose-prerender-handler-id.
339
+ * Accessed by other plugins via config.plugins.find(p => p.name === "...").api
340
+ */
341
+ export interface ExposePrerenderHandlerIdApi {
342
+ /** Tracks absolute module IDs that contain prerender handler exports.
343
+ * key: absolute module ID (filesystem path)
344
+ * value: array of export names (e.g., ["ArticlesIndex", "ArticleDetail"]) */
345
+ prerenderHandlerModules: Map<string, string[]>;
346
+ }
347
+
348
+ /**
349
+ * Vite plugin that exposes $$id on createPrerenderHandler calls.
350
+ *
351
+ * When users create prerender handlers with createPrerenderHandler(), this plugin:
352
+ * - RSC environment: Injects $$id into the call and sets a $$id property on the export
353
+ * - Non-RSC environments (client/SSR): Replaces createPrerenderHandler(...) call
354
+ * expressions with lightweight { __brand, $$id } stubs, keeping other exports intact
355
+ *
356
+ * Requirements:
357
+ * - Must use direct import: import { createPrerenderHandler } from "@rangojs/router"
358
+ * - Must use named export: export const MyPage = createPrerenderHandler(...)
359
+ *
360
+ * Other plugins can read handler module data via the plugin API:
361
+ * config.plugins.find(p => p.name === "@rangojs/router:expose-prerender-handler-id")?.api
362
+ */
363
+ export function exposePrerenderHandlerId(): Plugin {
364
+ let config: ResolvedConfig;
365
+ let isBuild = false;
366
+ const prerenderHandlerModules: Map<string, string[]> = new Map();
367
+
368
+ return {
369
+ name: "@rangojs/router:expose-prerender-handler-id",
370
+ enforce: "post",
371
+
372
+ api: {
373
+ prerenderHandlerModules,
374
+ } satisfies ExposePrerenderHandlerIdApi,
375
+
376
+ configResolved(resolvedConfig) {
377
+ config = resolvedConfig;
378
+ isBuild = config.command === "build";
379
+ },
380
+
381
+ transform(code, id) {
382
+ // Skip node_modules
383
+ if (id.includes("/node_modules/")) {
384
+ return;
385
+ }
386
+
387
+ // Quick bail-out
388
+ if (!code.includes("createPrerenderHandler")) {
389
+ return;
390
+ }
391
+
392
+ // Must have direct import from @rangojs/router
393
+ if (!hasCreatePrerenderHandlerImport(code)) {
394
+ return;
395
+ }
396
+
397
+ // Get relative path for the ID
398
+ const relativePath = normalizePath(path.relative(config.root, id));
399
+
400
+ const isRscEnv = this.environment?.name === "rsc";
401
+
402
+ if (!isRscEnv) {
403
+ // Non-RSC: try whole-file replacement first (all exports are handlers),
404
+ // fall back to per-expression replacement for mixed-export files
405
+ return (
406
+ generateWholeFileHandlerStubs(code, relativePath, isBuild) ??
407
+ generatePrerenderHandlerStubs(code, relativePath, id, isBuild)
408
+ );
409
+ }
410
+
411
+ // RSC build: record this module and its handler export names for chunk isolation
412
+ if (isBuild) {
413
+ const handlerPattern =
414
+ /export\s+const\s+(\w+)\s*=\s*createPrerenderHandler\s*(?:<[^>]*>)?\s*\(/g;
415
+ const exportNames: string[] = [];
416
+ let m: RegExpExecArray | null;
417
+ while ((m = handlerPattern.exec(code)) !== null) {
418
+ exportNames.push(m[1]);
419
+ }
420
+ if (exportNames.length > 0) {
421
+ prerenderHandlerModules.set(id, exportNames);
422
+ }
423
+ }
424
+
425
+ // RSC: inject $$id into calls (existing behavior)
426
+ return transformPrerenderHandlerExports(code, relativePath, id, isBuild);
427
+ },
428
+ };
429
+ }