@rangojs/router 0.0.0-experimental.13 → 0.0.0-experimental.15
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/README.md +589 -4
- package/dist/bin/rango.js +425 -204
- package/dist/vite/index.js +116 -326
- package/package.json +1 -1
- package/skills/rango/SKILL.md +63 -0
- package/skills/testing/SKILL.md +226 -0
- package/skills/typesafety/SKILL.md +49 -31
- package/src/bin/rango.ts +63 -12
- package/src/build/generate-route-types.ts +260 -450
- package/src/index.rsc.ts +13 -1
- package/src/index.ts +7 -0
- package/src/route-definition.ts +31 -0
- package/src/router/match-middleware/cache-store.ts +1 -1
- package/src/router.ts +13 -6
- package/src/server.ts +13 -158
- package/src/urls.ts +29 -0
- package/src/vite/index.ts +9 -17
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: testing
|
|
3
|
+
description: Unit test route trees with buildRouteTree()
|
|
4
|
+
argument-hint:
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
# Route Tree Unit Testing
|
|
8
|
+
|
|
9
|
+
Unit test route definitions by inspecting the route tree, segment IDs, middleware, intercepts, loaders, and pattern matching without running a dev server.
|
|
10
|
+
|
|
11
|
+
## Setup
|
|
12
|
+
|
|
13
|
+
The `buildRouteTree` helper lives in `src/__tests__/helpers/route-tree.ts` (not shipped with npm). Import it in your test files:
|
|
14
|
+
|
|
15
|
+
```typescript
|
|
16
|
+
import { buildRouteTree } from "./helpers/route-tree.js";
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
## buildRouteTree(urlPatterns)
|
|
20
|
+
|
|
21
|
+
Takes a `urls()` result and returns a `RouteTree` with inspection methods:
|
|
22
|
+
|
|
23
|
+
```typescript
|
|
24
|
+
import { urls } from "@rangojs/router";
|
|
25
|
+
import { buildRouteTree } from "./helpers/route-tree.js";
|
|
26
|
+
|
|
27
|
+
const tree = buildRouteTree(
|
|
28
|
+
urls(({ path, layout, middleware, loader, intercept, when }) => [
|
|
29
|
+
layout(RootLayout, () => [
|
|
30
|
+
middleware(authMiddleware),
|
|
31
|
+
path("/", HomePage, { name: "home" }),
|
|
32
|
+
path("/blog/:slug", BlogPost, { name: "blog.post" }, () => [
|
|
33
|
+
loader(PostLoader),
|
|
34
|
+
]),
|
|
35
|
+
]),
|
|
36
|
+
])
|
|
37
|
+
);
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
## RouteTree API
|
|
41
|
+
|
|
42
|
+
### Route Patterns
|
|
43
|
+
|
|
44
|
+
```typescript
|
|
45
|
+
tree.routes() // { home: "/", "blog.post": "/blog/:slug" }
|
|
46
|
+
tree.routeNames() // ["home", "blog.post"]
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
### URL Matching
|
|
50
|
+
|
|
51
|
+
```typescript
|
|
52
|
+
const m = tree.match("/blog/hello");
|
|
53
|
+
m.routeKey // "blog.post"
|
|
54
|
+
m.params // { slug: "hello" }
|
|
55
|
+
|
|
56
|
+
tree.match("/nonexistent") // null
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
### Segment IDs
|
|
60
|
+
|
|
61
|
+
```typescript
|
|
62
|
+
tree.segmentId("home") // "M0L0L0R0"
|
|
63
|
+
tree.segmentIds() // { home: "M0L0L0R0", "blog.post": "M0L0L0R1" }
|
|
64
|
+
tree.segmentPath("blog.post")
|
|
65
|
+
// [
|
|
66
|
+
// { id: "M0L0", type: "layout" }, // synthetic root
|
|
67
|
+
// { id: "M0L0L0", type: "layout" }, // RootLayout
|
|
68
|
+
// { id: "M0L0L0R1", type: "route", pattern: "/blog/:slug" },
|
|
69
|
+
// ]
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
### Entry Access
|
|
73
|
+
|
|
74
|
+
```typescript
|
|
75
|
+
tree.entry("blog.post") // EntryData
|
|
76
|
+
tree.entry("blog.post")!.parent!.type // "layout"
|
|
77
|
+
tree.entryByPattern("/blog/:slug") // EntryData (lookup by URL pattern)
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
### Middleware
|
|
81
|
+
|
|
82
|
+
```typescript
|
|
83
|
+
tree.hasMiddleware("home") // true (inherited from layout)
|
|
84
|
+
tree.middleware("home") // [authMiddleware] (direct only)
|
|
85
|
+
tree.middlewareChain("home")
|
|
86
|
+
// [{ segmentId: "M0L0L0", count: 1 }] // all middleware root-to-route
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
### Loaders
|
|
90
|
+
|
|
91
|
+
```typescript
|
|
92
|
+
tree.hasLoaders("blog.post") // true
|
|
93
|
+
tree.loaders("blog.post") // [LoaderEntry { loader, revalidate, cache? }]
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
### Intercepts
|
|
97
|
+
|
|
98
|
+
```typescript
|
|
99
|
+
tree.intercepts("home")
|
|
100
|
+
// [{ slotName: "@modal", routeName: "card", hasWhen: true, whenCount: 1, hasLoader: false, hasMiddleware: false }]
|
|
101
|
+
tree.interceptEntries("home") // raw InterceptEntry[]
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
### Parallel Slots
|
|
105
|
+
|
|
106
|
+
```typescript
|
|
107
|
+
tree.parallelSlots("home") // EntryData[] of type="parallel"
|
|
108
|
+
tree.parallelSlotNames("home") // ["@sidebar", "@main"]
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
### Boundaries
|
|
112
|
+
|
|
113
|
+
```typescript
|
|
114
|
+
tree.hasErrorBoundary("home") // boolean
|
|
115
|
+
tree.hasNotFoundBoundary("home") // boolean
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
### Cache & Loading
|
|
119
|
+
|
|
120
|
+
```typescript
|
|
121
|
+
tree.hasCache("home") // boolean
|
|
122
|
+
tree.hasLoading("home") // boolean
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
### Debug
|
|
126
|
+
|
|
127
|
+
```typescript
|
|
128
|
+
console.log(tree.debug());
|
|
129
|
+
// Route Tree:
|
|
130
|
+
// home: / [M0L0L0R0] (M0L0 > M0L0L0 > M0L0L0R0) {mw:1}
|
|
131
|
+
// blog.post: /blog/:slug [M0L0L0R1] (M0L0 > M0L0L0 > M0L0L0R1) {mw:1, ld:1}
|
|
132
|
+
```
|
|
133
|
+
|
|
134
|
+
## Segment ID Format
|
|
135
|
+
|
|
136
|
+
| Prefix | Meaning |
|
|
137
|
+
|--------|---------|
|
|
138
|
+
| `M0` | Mount index (router instance) |
|
|
139
|
+
| `L` | Layout |
|
|
140
|
+
| `R` | Route |
|
|
141
|
+
| `P` | Parallel slot |
|
|
142
|
+
| `D` | Loader (data) |
|
|
143
|
+
| `C` | Cache boundary |
|
|
144
|
+
|
|
145
|
+
Example: `M0L0L0R1` = mount 0, synthetic root layout, user layout, second route.
|
|
146
|
+
|
|
147
|
+
## Examples
|
|
148
|
+
|
|
149
|
+
### include() with prefix
|
|
150
|
+
|
|
151
|
+
```typescript
|
|
152
|
+
const blogPatterns = urls(({ path }) => [
|
|
153
|
+
path("/", BlogIndex, { name: "index" }),
|
|
154
|
+
path("/:slug", BlogPost, { name: "post" }),
|
|
155
|
+
]);
|
|
156
|
+
|
|
157
|
+
const tree = buildRouteTree(
|
|
158
|
+
urls(({ path, include }) => [
|
|
159
|
+
path("/", HomePage, { name: "home" }),
|
|
160
|
+
include("/blog", blogPatterns, { name: "blog" }),
|
|
161
|
+
])
|
|
162
|
+
);
|
|
163
|
+
|
|
164
|
+
expect(tree.routes()).toEqual({
|
|
165
|
+
home: "/",
|
|
166
|
+
"blog.index": "/blog",
|
|
167
|
+
"blog.post": "/blog/:slug",
|
|
168
|
+
});
|
|
169
|
+
```
|
|
170
|
+
|
|
171
|
+
### Middleware chain
|
|
172
|
+
|
|
173
|
+
```typescript
|
|
174
|
+
const authMw = async (ctx, next) => next();
|
|
175
|
+
const logMw = async (ctx, next) => next();
|
|
176
|
+
|
|
177
|
+
const tree = buildRouteTree(
|
|
178
|
+
urls(({ path, layout, middleware }) => [
|
|
179
|
+
layout(RootLayout, () => [
|
|
180
|
+
middleware(logMw),
|
|
181
|
+
layout(AuthLayout, () => [
|
|
182
|
+
middleware(authMw),
|
|
183
|
+
path("/dashboard", Dashboard, { name: "dashboard" }),
|
|
184
|
+
]),
|
|
185
|
+
]),
|
|
186
|
+
])
|
|
187
|
+
);
|
|
188
|
+
|
|
189
|
+
expect(tree.middlewareChain("dashboard")).toEqual([
|
|
190
|
+
{ segmentId: "M0L0L0", count: 1 }, // logMw on RootLayout
|
|
191
|
+
{ segmentId: "M0L0L0L0", count: 1 }, // authMw on AuthLayout
|
|
192
|
+
]);
|
|
193
|
+
```
|
|
194
|
+
|
|
195
|
+
### Intercepts with when()
|
|
196
|
+
|
|
197
|
+
```typescript
|
|
198
|
+
const tree = buildRouteTree(
|
|
199
|
+
urls(({ path, layout, intercept, when }) => [
|
|
200
|
+
layout(ShopLayout, () => [
|
|
201
|
+
path("/products", ProductList, { name: "products" }),
|
|
202
|
+
path("/products/:id", ProductDetail, { name: "product.detail" }),
|
|
203
|
+
intercept("@modal", "product.detail", ProductModal, () => [
|
|
204
|
+
when((ctx) => ctx.from.pathname.startsWith("/products")),
|
|
205
|
+
]),
|
|
206
|
+
]),
|
|
207
|
+
])
|
|
208
|
+
);
|
|
209
|
+
|
|
210
|
+
const intercepts = tree.intercepts("products");
|
|
211
|
+
// Note: intercepts are on the parent where intercept() is called
|
|
212
|
+
```
|
|
213
|
+
|
|
214
|
+
### Constrained parameters
|
|
215
|
+
|
|
216
|
+
```typescript
|
|
217
|
+
const tree = buildRouteTree(
|
|
218
|
+
urls(({ path }) => [
|
|
219
|
+
path("/:locale(en|fr)?/about", AboutPage, { name: "about" }),
|
|
220
|
+
])
|
|
221
|
+
);
|
|
222
|
+
|
|
223
|
+
expect(tree.match("/about")).not.toBeNull();
|
|
224
|
+
expect(tree.match("/fr/about")!.params).toEqual({ locale: "fr" });
|
|
225
|
+
expect(tree.match("/de/about")).toBeNull();
|
|
226
|
+
```
|
|
@@ -45,22 +45,35 @@ export const urlpatterns = urls(({ path, layout }) => [
|
|
|
45
45
|
|
|
46
46
|
## Type-Safe href()
|
|
47
47
|
|
|
48
|
-
### Server: ctx.reverse
|
|
48
|
+
### Server: ctx.reverse with global route names
|
|
49
49
|
|
|
50
|
-
In route handlers, use `
|
|
50
|
+
In route handlers, use `ctx.reverse()` with the global dotted route names
|
|
51
|
+
from `router.named-routes.gen.ts`:
|
|
51
52
|
|
|
52
53
|
```typescript
|
|
53
|
-
import {
|
|
54
|
-
|
|
55
|
-
path("/product/:slug", (ctx) => {
|
|
56
|
-
const reverse = scopedReverse<typeof shopPatterns>(ctx.reverse);
|
|
54
|
+
import type { Handler } from "@rangojs/router";
|
|
57
55
|
|
|
58
|
-
|
|
59
|
-
reverse("
|
|
60
|
-
reverse("
|
|
56
|
+
export const ProductHandler: Handler<"shop.product"> = (ctx) => {
|
|
57
|
+
ctx.reverse("shop.cart"); // Global name
|
|
58
|
+
ctx.reverse("shop.product", { slug: "widget" }); // With params
|
|
59
|
+
ctx.reverse("blog.post", { postId: "1" }); // Cross-module
|
|
61
60
|
|
|
62
61
|
return <ProductPage slug={ctx.params.slug} />;
|
|
63
|
-
}
|
|
62
|
+
};
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
For opt-in per-module isolation (after running `npx rango generate urls/shop.tsx`),
|
|
66
|
+
use `scopedReverse()` with a local route map:
|
|
67
|
+
|
|
68
|
+
```typescript
|
|
69
|
+
import { scopedReverse } from "@rangojs/router";
|
|
70
|
+
import type { routes } from "./shop.gen.js";
|
|
71
|
+
|
|
72
|
+
export const ProductHandler: Handler<"product", routes> = (ctx) => {
|
|
73
|
+
const reverse = scopedReverse<routes>(ctx.reverse);
|
|
74
|
+
reverse("cart"); // Local name
|
|
75
|
+
reverse("product", { slug: "widget" }); // Local with params
|
|
76
|
+
};
|
|
64
77
|
```
|
|
65
78
|
|
|
66
79
|
### Client: href + useHref
|
|
@@ -192,13 +205,14 @@ export const SearchPage: Handler<"search"> = (ctx) => {
|
|
|
192
205
|
```
|
|
193
206
|
|
|
194
207
|
This avoids circular references because `Handler` defaults to `GeneratedRouteMap`
|
|
195
|
-
(
|
|
208
|
+
(from `router.named-routes.gen.ts`) instead of `RegisteredRoutes` (which depends on `router.tsx`).
|
|
196
209
|
|
|
197
|
-
You can also pass an explicit route map
|
|
210
|
+
You can also pass an explicit route map for per-module isolation (opt-in,
|
|
211
|
+
after running `npx rango generate`):
|
|
198
212
|
|
|
199
213
|
```typescript
|
|
200
214
|
import type { Handler } from "@rangojs/router";
|
|
201
|
-
import type { routes } from "
|
|
215
|
+
import type { routes } from "./urls.gen.js";
|
|
202
216
|
|
|
203
217
|
export const SearchPage: Handler<"search", routes> = (ctx) => { ... };
|
|
204
218
|
```
|
|
@@ -240,16 +254,15 @@ type P = RouteParams<"blogPost", routes>;
|
|
|
240
254
|
|
|
241
255
|
### Generated route types
|
|
242
256
|
|
|
243
|
-
In the generated
|
|
244
|
-
|
|
257
|
+
In the generated `router.named-routes.gen.ts`, routes with search schemas
|
|
258
|
+
use `{ path, search }` objects:
|
|
245
259
|
|
|
246
260
|
```typescript
|
|
247
|
-
//
|
|
248
|
-
export const
|
|
249
|
-
search: { path: "/search", search: { q: "string", page: "number?", sort: "string?" } },
|
|
250
|
-
home: "/", // No search schema -> plain string
|
|
261
|
+
// router.named-routes.gen.ts (auto-generated)
|
|
262
|
+
export const NamedRoutes = {
|
|
263
|
+
"search.index": { path: "/search", search: { q: "string", page: "number?", sort: "string?" } },
|
|
264
|
+
"home.index": "/", // No search schema -> plain string
|
|
251
265
|
} as const;
|
|
252
|
-
export type routes = typeof routes;
|
|
253
266
|
```
|
|
254
267
|
|
|
255
268
|
## Loader Type Safety
|
|
@@ -426,9 +439,10 @@ global type declarations (like `RSCRouter.Env`).
|
|
|
426
439
|
}
|
|
427
440
|
```
|
|
428
441
|
|
|
429
|
-
The `files` array ensures `router.tsx` (which contains `declare global { namespace RSCRouter {
|
|
430
|
-
is always included in the compilation even if nothing directly imports it.
|
|
431
|
-
|
|
442
|
+
The `files` array ensures `router.tsx` (which contains `declare global { namespace RSCRouter { interface Env } }`)
|
|
443
|
+
is always included in the compilation even if nothing directly imports it. Route types come from the
|
|
444
|
+
auto-generated `*.named-routes.gen.ts` file (via `rango generate`), not from manual declaration.
|
|
445
|
+
Each app gets its own typed environment without interfering with other apps.
|
|
432
446
|
|
|
433
447
|
## Complete Type-Safe Setup
|
|
434
448
|
|
|
@@ -450,21 +464,26 @@ export const urlpatterns = urls(({ path, layout, loader }) => [
|
|
|
450
464
|
]),
|
|
451
465
|
]);
|
|
452
466
|
|
|
453
|
-
// 3. router.tsx -
|
|
467
|
+
// 3. router.tsx - Create router and export reverse
|
|
454
468
|
const router = createRouter<AppEnv>({
|
|
455
469
|
document: Document,
|
|
456
|
-
|
|
457
|
-
});
|
|
470
|
+
}).routes(urlpatterns);
|
|
458
471
|
|
|
472
|
+
// Optional: register environment type globally for implicit typing
|
|
459
473
|
declare global {
|
|
460
474
|
namespace RSCRouter {
|
|
461
475
|
interface Env extends AppEnv {}
|
|
462
476
|
}
|
|
463
477
|
}
|
|
464
478
|
|
|
479
|
+
export const reverse = router.reverse;
|
|
465
480
|
export default router;
|
|
466
481
|
|
|
467
|
-
// 4.
|
|
482
|
+
// 4. Run `npx rango generate src/router.tsx` to generate
|
|
483
|
+
// router.named-routes.gen.ts (auto-registers GeneratedRouteMap globally).
|
|
484
|
+
// No manual RegisteredRoutes declaration needed.
|
|
485
|
+
|
|
486
|
+
// 5. loaders/*.ts - Type-safe loaders
|
|
468
487
|
export const ProductLoader = createLoader("product", async (ctx) => {
|
|
469
488
|
// ctx.params: { slug: string }
|
|
470
489
|
// ctx.env.Variables.user: User | undefined
|
|
@@ -472,13 +491,12 @@ export const ProductLoader = createLoader("product", async (ctx) => {
|
|
|
472
491
|
return { product: await fetchProduct(ctx.params.slug) };
|
|
473
492
|
});
|
|
474
493
|
|
|
475
|
-
//
|
|
494
|
+
// 6. Server: ctx.reverse for named routes
|
|
476
495
|
path("/product/:slug", (ctx) => {
|
|
477
|
-
|
|
478
|
-
return <Link to={reverse("shop")}>Back to Shop</Link>;
|
|
496
|
+
return <Link to={ctx.reverse("shop")}>Back to Shop</Link>;
|
|
479
497
|
}, { name: "product" })
|
|
480
498
|
|
|
481
|
-
//
|
|
499
|
+
// 7. Client: useHref for mounted paths, href for absolute
|
|
482
500
|
"use client";
|
|
483
501
|
import { useHref, href, Link } from "@rangojs/router/client";
|
|
484
502
|
<Link to={href("/shop/product/widget")}>Widget</Link>
|
package/src/bin/rango.ts
CHANGED
|
@@ -1,24 +1,75 @@
|
|
|
1
|
-
import { resolve } from "node:path";
|
|
2
|
-
import {
|
|
1
|
+
import { resolve, dirname, extname } from "node:path";
|
|
2
|
+
import { readFileSync, statSync } from "node:fs";
|
|
3
|
+
import { findTsFiles, writePerModuleRouteTypesForFile, writeCombinedRouteTypes } from "../build/generate-route-types.ts";
|
|
3
4
|
|
|
4
5
|
const [command, ...args] = process.argv.slice(2);
|
|
5
6
|
|
|
6
|
-
if (command === "
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
7
|
+
if (command === "generate") {
|
|
8
|
+
if (args.length === 0) {
|
|
9
|
+
console.error("[rango] Usage: rango generate <file|dir> [file2 ...]");
|
|
10
|
+
process.exit(1);
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
// Expand args: files are used directly, directories are scanned
|
|
14
|
+
const files: string[] = [];
|
|
15
|
+
for (const arg of args) {
|
|
16
|
+
const resolved = resolve(arg);
|
|
17
|
+
try {
|
|
18
|
+
if (statSync(resolved).isDirectory()) {
|
|
19
|
+
files.push(...findTsFiles(resolved));
|
|
20
|
+
} else {
|
|
21
|
+
files.push(resolved);
|
|
22
|
+
}
|
|
23
|
+
} catch {
|
|
24
|
+
console.warn(`[rango] Skipping ${arg}: not found`);
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
if (files.length === 0) {
|
|
29
|
+
console.log("[rango] No files to process");
|
|
30
|
+
process.exit(0);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const routerFiles: string[] = [];
|
|
10
34
|
|
|
11
|
-
const files = findTsFiles(resolvedDir);
|
|
12
35
|
for (const filePath of files) {
|
|
13
|
-
|
|
36
|
+
try {
|
|
37
|
+
const source = readFileSync(filePath, "utf-8");
|
|
38
|
+
|
|
39
|
+
// Detect file type and generate accordingly
|
|
40
|
+
const isRouter = /\bcreateRouter\s*[<(]/.test(source);
|
|
41
|
+
const isUrls = source.includes("urls(");
|
|
42
|
+
|
|
43
|
+
if (isRouter) {
|
|
44
|
+
routerFiles.push(filePath);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
if (isUrls) {
|
|
48
|
+
writePerModuleRouteTypesForFile(filePath);
|
|
49
|
+
}
|
|
50
|
+
} catch (err) {
|
|
51
|
+
console.warn(`[rango] Failed to process ${filePath}: ${(err as Error).message}`);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// Generate named-routes for any detected router files
|
|
56
|
+
for (const routerFile of routerFiles) {
|
|
57
|
+
writeCombinedRouteTypes(dirname(routerFile), [routerFile]);
|
|
14
58
|
}
|
|
15
59
|
|
|
16
|
-
console.log(`[rango]
|
|
60
|
+
console.log(`[rango] Processed ${files.length} file(s)${routerFiles.length ? ` (${routerFiles.length} router)` : ""}`);
|
|
17
61
|
process.exit(0);
|
|
18
62
|
} else {
|
|
19
|
-
console.log(`Usage: rango <
|
|
63
|
+
console.log(`Usage: rango generate <file|dir> [file2 ...]
|
|
64
|
+
|
|
65
|
+
Auto-detects file type (createRouter, urls) and generates
|
|
66
|
+
the appropriate .gen.ts route type files.
|
|
67
|
+
|
|
68
|
+
Pass files, directories, or a mix of both.
|
|
20
69
|
|
|
21
|
-
|
|
22
|
-
|
|
70
|
+
Examples:
|
|
71
|
+
rango generate src/urls.tsx
|
|
72
|
+
rango generate src/router.tsx src/urls.tsx
|
|
73
|
+
rango generate src/`);
|
|
23
74
|
process.exit(command ? 1 : 0);
|
|
24
75
|
}
|