Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions .changeset/direct-route-composition.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
---
"@bomb.sh/router": minor
---

Allows `route()`, `command()`, and `extend()` to accept child routes and
commands directly, preserving inferred route paths, methods, and models.

The public `routes()` helper has been removed. Pass children directly inside
`route()` and `command()`, and use `extend(child, other)` for reusable groups or
dynamic resolver results.
29 changes: 14 additions & 15 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,6 @@ import {
name,
option,
route,
routes,
schema,
toggle,
version,
Expand All @@ -52,19 +51,15 @@ export const app = command(
description("Run and manage local service simulators."),
version("1.0.0"),
toggle(name("verbose")),
routes(
command(
name("serve"),
option(name("port"), schema(z.number().default(4000))),
),
route(
name("database"),
command(
name("serve"),
option(name("port"), schema(z.number().default(4000))),
),
route(
name("database"),
routes(
command(
name("clean"),
toggle(name("dryRun")),
),
),
name("clean"),
toggle(name("dryRun")),
),
),
);
Expand All @@ -79,6 +74,10 @@ HELP /database
HELP /database/clean EXECUTE /database/clean
```

Nest `route()` and `command()` directly inside another route, command, or
`extend()`. Each child is mounted at that point in the pipeline. Use `extend()`
to package reusable groups of children and other elements.

The root name identifies the executable; it is not repeated in route IDs.
`simulacrum serve` therefore selects `/serve`, not `/simulacrum/serve`.

Expand Down Expand Up @@ -200,14 +199,14 @@ to `resume()`. The schema's input type determines what `resume()` accepts; its
validated output is passed to the extension, including any schema transforms.

```ts
import { command, dynamic, name, parse, routes } from "@bomb.sh/router";
import { command, dynamic, extend, name, parse } from "@bomb.sh/router";
import * as z from "zod";

const app = command(
name("plugins"),
dynamic(
z.array(z.string()),
(plugins) => routes(...plugins.map((plugin) => command(name(plugin)))),
(plugins) => extend(...plugins.map((plugin) => command(name(plugin)))),
),
);

Expand Down
41 changes: 18 additions & 23 deletions examples/routes-checkpoint.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,6 @@ import {
name,
option,
route,
routes,
schema,
toggle,
version,
Expand All @@ -18,30 +17,26 @@ export const app = command(
description("Run and manage local service simulators."),
version("1.0.0"),
toggle(name("verbose")),
routes(
command(
name("serve"),
option(name("port"), description("server port"), schema(z.number())),
checkpoint(),
option(name("host"), description("server host"), schema(z.string())),
option(
name("protocol"),
description("server protocol"),
schema(z.enum(["http", "https"])),
),
),
route(
name("database"),
command(
name("serve"),
option(name("port"), description("server port"), schema(z.number())),
checkpoint(),
option(name("host"), description("server host"), schema(z.string())),
name("clean"),
toggle(name("dryRun")),
option(
name("protocol"),
description("server protocol"),
schema(z.enum(["http", "https"])),
),
),
route(
name("database"),
routes(
command(
name("clean"),
toggle(name("dryRun")),
option(
name("output"),
description("output path"),
schema(z.string()),
),
),
name("output"),
description("output path"),
schema(z.string()),
),
),
),
Expand Down
39 changes: 17 additions & 22 deletions examples/routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@ import {
name,
option,
route,
routes,
schema,
toggle,
version,
Expand All @@ -17,29 +16,25 @@ export const app = command(
description("Run and manage local service simulators."),
version("1.0.0"),
toggle(name("verbose")),
routes(
command(
name("serve"),
option(name("port"), description("server port"), schema(z.number())),
option(name("host"), description("server host"), schema(z.string())),
option(
name("protocol"),
description("server protocol"),
schema(z.enum(["http", "https"])),
),
),
route(
name("database"),
command(
name("serve"),
option(name("port"), description("server port"), schema(z.number())),
option(name("host"), description("server host"), schema(z.string())),
name("clean"),
toggle(name("dryRun")),
option(
name("protocol"),
description("server protocol"),
schema(z.enum(["http", "https"])),
),
),
route(
name("database"),
routes(
command(
name("clean"),
toggle(name("dryRun")),
option(
name("output"),
description("output path"),
schema(z.string()),
),
),
name("output"),
description("output path"),
schema(z.string()),
),
),
),
Expand Down
18 changes: 12 additions & 6 deletions lib/command.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,11 @@
// deno-lint-ignore-file ban-types
import type { Check, Fold, Materialize, Unary } from "./pipeline.ts";
import type { Fold, Materialize } from "./pipeline.ts";
import {
type Input,
type Normalize,
normalize,
type Validate,
} from "./route.ts";
import type { Definition, Done, Route } from "./types.ts";

export type CommandZero<N extends string = string> = Route<
Expand All @@ -10,11 +16,11 @@ export type CommandZero<N extends string = string> = Route<

export function command<
const N extends string,
const E extends readonly Unary[],
const E extends readonly Input[],
>(
start: Definition<N>,
...elements: E & Check<CommandZero<N>, E>
): Materialize<Fold<CommandZero<N>, E>> {
...elements: E & Validate<CommandZero<N>, E>
): Materialize<Fold<CommandZero<N>, Normalize<E>>> {
let zero: CommandZero<N> = {
...start,
methods: ["help", "execute"],
Expand All @@ -29,8 +35,8 @@ export function command<
}],
};

return elements.reduce<unknown>(
return normalize<E>(elements).reduce<unknown>(
(value, element) => element(value as never),
zero,
) as Materialize<Fold<CommandZero<N>, E>>;
) as Materialize<Fold<CommandZero<N>, Normalize<E>>>;
}
12 changes: 7 additions & 5 deletions lib/extend.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
import { brand, type Extension, type Unary } from "./pipeline.ts";
import { type Input, type Normalize, normalize } from "./route.ts";
import { brand, type Extension } from "./pipeline.ts";

export function extend<const E extends readonly Unary[]>(
export function extend<const E extends readonly Input[]>(
...elements: E
): Extension<E> {
return brand<Extension<E>>((start: unknown): unknown =>
elements.reduce<unknown>(
): Extension<Normalize<E>> {
let normalized = normalize(elements);
return brand<Extension<Normalize<E>>>((start: unknown): unknown =>
normalized.reduce<unknown>(
(value, element) => element(value as never),
start,
)
Expand Down
46 changes: 39 additions & 7 deletions lib/route.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
// deno-lint-ignore-file ban-types
import {
brand,
type Check,
type Fold,
type Materialize,
type MethodElement,
Expand All @@ -18,11 +17,11 @@ export type RouteZero<N extends string = string> = Route<

export function route<
const N extends string,
const E extends readonly Unary[],
const E extends readonly Input[],
>(
start: Definition<N>,
...elements: E & Check<RouteZero<N>, E>
): Materialize<Fold<RouteZero<N>, E>> {
...elements: E & Validate<RouteZero<N>, E>
): Materialize<Fold<RouteZero<N>, Normalize<E>>> {
let zero: RouteZero<N> = {
...start,
methods: ["help"],
Expand All @@ -37,10 +36,10 @@ export function route<
}],
};

return elements.reduce<unknown>(
return normalize<E>(elements).reduce<unknown>(
(value, element) => element(value as never),
zero,
) as Materialize<Fold<RouteZero<N>, E>>;
) as Materialize<Fold<RouteZero<N>, Normalize<E>>>;
}

export function version(semver: string): MethodElement<"version"> {
Expand All @@ -58,7 +57,25 @@ export function executable(): MethodElement<"execute"> {
}));
}

export function routes<const C extends readonly AnyRoute[]>(
export type Input = Unary | AnyRoute;

export type Normalize<E extends readonly Input[]> = {
[K in keyof E]: Normalized<E[K]>;
};

// Walk invalid pipelines only, reporting errors on the original arguments.
export type Validate<S, E extends readonly Input[]> =
[Fold<S, Normalize<E>>] extends [never] ? Invalid<S, E> : unknown;

export function normalize<const E extends readonly Input[]>(
elements: E,
): Normalize<E> {
return elements.map((element) =>
typeof element === "function" ? element : routes(element)
) as Normalize<E>;
}

function routes<const C extends readonly AnyRoute[]>(
...children: C
): RoutesElement<C> {
return brand<RoutesElement<C>>((route: AnyRoute) => {
Expand All @@ -75,3 +92,18 @@ export function routes<const C extends readonly AnyRoute[]>(
};
});
}

// Distribute over unions so Fold can retain its conservative union handling.
type Normalized<E extends Input> = E extends Unary ? E
: E extends AnyRoute ? RoutesElement<readonly [E]>
: never;

type Invalid<S, E extends readonly Input[]> = E extends readonly [
infer Head extends Input,
...infer Tail extends readonly Input[],
]
? Fold<S, readonly [Normalized<Head>]> extends infer Next
? [Next] extends [never] ? readonly [never, ...Tail]
: readonly [Head, ...Invalid<Next, Tail>]
: never
: E;
2 changes: 1 addition & 1 deletion mod.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ export type {
Symbol as CLISymbol,
} from "./lib/read.ts";

export { executable, route, routes, version } from "./lib/route.ts";
export { executable, route, version } from "./lib/route.ts";
export type { RouteZero } from "./lib/route.ts";

export { toggle } from "./lib/toggle.ts";
Expand Down
7 changes: 3 additions & 4 deletions test/argument.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,6 @@ import {
name,
option,
parse,
routes,
schema,
toggle,
} from "@bomb.sh/router";
Expand Down Expand Up @@ -168,7 +167,7 @@ describe("argument()", () => {
let app = command(
name("app"),
argument(name("workspace"), schema(type("string"))),
routes(auth0),
auth0,
);
let result = parse(app, {
argv: ["local", "auth0", "service.json"],
Expand All @@ -191,7 +190,7 @@ describe("argument()", () => {
let app = command(
name("app"),
argument(name("workspace"), schema(type("string | undefined"))),
routes(auth0),
auth0,
);
let result = parse(app, { argv: ["auth0"] });

Expand Down Expand Up @@ -345,7 +344,7 @@ describe("argument()", () => {
argument(name("target"), schema(type("string"))),
dynamic(type("unknown"), () =>
extend(
routes(command(name("auth0"))),
command(name("auth0")),
)),
);
let increment = parse(app, { argv: ["auth0"] });
Expand Down
Loading
Loading