From efca8c6bc80c7d5c124e1f907e4042cc2bcd0b81 Mon Sep 17 00:00:00 2001 From: fcrozatier Date: Fri, 14 Nov 2025 17:44:56 +0100 Subject: [PATCH 01/46] first draft --- packages/typed-url-pattern/deno.json | 8 +++ .../typed-url-pattern/src/typedURLPattern.ts | 69 +++++++++++++++++++ 2 files changed, 77 insertions(+) create mode 100644 packages/typed-url-pattern/deno.json create mode 100644 packages/typed-url-pattern/src/typedURLPattern.ts diff --git a/packages/typed-url-pattern/deno.json b/packages/typed-url-pattern/deno.json new file mode 100644 index 0000000..6f26dc4 --- /dev/null +++ b/packages/typed-url-pattern/deno.json @@ -0,0 +1,8 @@ +{ + "name": "@f-stack/typed-url-pattern", + "version": "0.1.0", + "license": "MIT", + "exports": { + ".": "./src/index.ts" + } +} diff --git a/packages/typed-url-pattern/src/typedURLPattern.ts b/packages/typed-url-pattern/src/typedURLPattern.ts new file mode 100644 index 0000000..f21a4a6 --- /dev/null +++ b/packages/typed-url-pattern/src/typedURLPattern.ts @@ -0,0 +1,69 @@ +export class TypedURLPattern< + T extends { + params?: Record; + searchParams?: Record; + hash?: string; + }, +> { + #pattern: URLPattern; + #baseURL: string | undefined; + + // Provide a default baseURL + constructor(input: URLPatternInput, baseURL?: string) { + this.#pattern = new URLPattern(input, baseURL); + this.#baseURL = baseURL; + } + + match(input: URLPatternInput) { + const match = this.#pattern.exec(input, this.#baseURL); + if (!match) return null; + + return { + protocol: match.protocol.input, + username: match.username.input, + password: match.password.input, + hostname: match.hostname.input, + port: match.port.input, + pathname: match.pathname.input, + params: match?.pathname.groups as T["params"], + search: match?.search.input, + searchGroups: match?.search.groups, + searchParams: new URLSearchParams(match?.search.input), + hash: match?.search.input, + }; + } + + href(options: T): string { + const pattern = this.#pattern; + + const protocol = pattern.protocol ? pattern.protocol + "://" : ""; + const username = pattern.username ? pattern.username + "@" : ""; + const port = pattern.port ? ":" + pattern.port : ""; + + let pathname = pattern.pathname; + + if (options.params) { + for (const [key, value] of Object.entries(options.params)) { + pathname = pathname.replace(key, encodeURIComponent(value)); + } + } + + let search = ""; + + if (options.searchParams) { + const entries: string[] = []; + for (const [key, value] of Object.entries(options.searchParams)) { + entries.push(`${key}=${encodeURIComponent(value)}`); + } + + if (entries.length) { + search = `?${entries.join("&")}`; + } + } + + const hash = options.hash ? "#" + options.hash : ""; + + return protocol + username + pattern.hostname + port + pathname + search + + hash; + } +} From a18d6d5721224b8bf2f6761acdf84401230d94cc Mon Sep 17 00:00:00 2001 From: fcrozatier Date: Fri, 14 Nov 2025 19:18:43 +0100 Subject: [PATCH 02/46] use Standard Schema --- packages/typed-url-pattern/deno.json | 4 + .../typed-url-pattern/src/typedURLPattern.ts | 86 ++++++++++++++++--- 2 files changed, 76 insertions(+), 14 deletions(-) diff --git a/packages/typed-url-pattern/deno.json b/packages/typed-url-pattern/deno.json index 6f26dc4..7e397aa 100644 --- a/packages/typed-url-pattern/deno.json +++ b/packages/typed-url-pattern/deno.json @@ -4,5 +4,9 @@ "license": "MIT", "exports": { ".": "./src/index.ts" + }, + "imports": { + "@standard-schema/spec": "jsr:@standard-schema/spec@^1.0.0", + "zod": "npm:zod@^4.1.12" } } diff --git a/packages/typed-url-pattern/src/typedURLPattern.ts b/packages/typed-url-pattern/src/typedURLPattern.ts index f21a4a6..01ec031 100644 --- a/packages/typed-url-pattern/src/typedURLPattern.ts +++ b/packages/typed-url-pattern/src/typedURLPattern.ts @@ -1,23 +1,63 @@ +import type { StandardSchemaV1 } from "@standard-schema/spec"; +import { assert } from "@std/assert/assert"; + export class TypedURLPattern< - T extends { - params?: Record; - searchParams?: Record; - hash?: string; - }, + T extends StandardSchemaV1, + U extends StandardSchemaV1, > { #pattern: URLPattern; - #baseURL: string | undefined; + #paramsSchema: T | undefined; + #searchParamsSchema: U | undefined; // Provide a default baseURL - constructor(input: URLPatternInput, baseURL?: string) { - this.#pattern = new URLPattern(input, baseURL); - this.#baseURL = baseURL; + constructor( + input: URLPatternInput, + schema?: { + params?: T; + searchParams?: U; + }, + ) { + this.#pattern = new URLPattern(input); + this.#paramsSchema = schema?.params; + this.#searchParamsSchema = schema?.searchParams; } - match(input: URLPatternInput) { - const match = this.#pattern.exec(input, this.#baseURL); + match(input: URLPatternInput, baseURL?: string) { + const match = this.#pattern.exec(input, baseURL); if (!match) return null; + const params = match?.pathname.groups; + const paramsSchema = this.#paramsSchema; + + let parsedParams; + + if (paramsSchema && params) { + const result = paramsSchema["~standard"].validate(params); + + if (result instanceof Promise) { + throw new TypeError("URL Pattern validation must be synchronous"); + } + + if (result.issues) return null; + parsedParams = result.value; + } + + const searchParams = match?.search; + const searchParamsSchema = this.#searchParamsSchema; + + let parsedSearchParams; + + if (searchParamsSchema && searchParams) { + const result = searchParamsSchema["~standard"].validate(searchParams); + + if (result instanceof Promise) { + throw new TypeError("URL Pattern validation must be synchronous"); + } + + if (result.issues) return null; + parsedSearchParams = result.value; + } + return { protocol: match.protocol.input, username: match.username.input, @@ -25,15 +65,21 @@ export class TypedURLPattern< hostname: match.hostname.input, port: match.port.input, pathname: match.pathname.input, - params: match?.pathname.groups as T["params"], + params: parsedParams as StandardSchemaV1.InferOutput, search: match?.search.input, searchGroups: match?.search.groups, - searchParams: new URLSearchParams(match?.search.input), + searchParams: parsedSearchParams as StandardSchemaV1.InferOutput, hash: match?.search.input, }; } - href(options: T): string { + href( + options: { + params?: StandardSchemaV1.InferInput; + searchParams?: StandardSchemaV1.InferInput; + hash?: string; + }, + ): string { const pattern = this.#pattern; const protocol = pattern.protocol ? pattern.protocol + "://" : ""; @@ -44,6 +90,12 @@ export class TypedURLPattern< if (options.params) { for (const [key, value] of Object.entries(options.params)) { + assert( + typeof value === "string" || + typeof value === "number" || + typeof value === "boolean", + "Params must be strings, numbers or booleans", + ); pathname = pathname.replace(key, encodeURIComponent(value)); } } @@ -53,6 +105,12 @@ export class TypedURLPattern< if (options.searchParams) { const entries: string[] = []; for (const [key, value] of Object.entries(options.searchParams)) { + assert( + typeof value === "string" || + typeof value === "number" || + typeof value === "boolean", + "SearchParams must be strings, numbers or booleans", + ); entries.push(`${key}=${encodeURIComponent(value)}`); } From d1909639e0e4f450c2671cb73e5410314cc56e44 Mon Sep 17 00:00:00 2001 From: fcrozatier Date: Sat, 15 Nov 2025 09:11:04 +0100 Subject: [PATCH 03/46] add a debug mode --- .../typed-url-pattern/src/typedURLPattern.ts | 17 +++++++++++++---- 1 file changed, 13 insertions(+), 4 deletions(-) diff --git a/packages/typed-url-pattern/src/typedURLPattern.ts b/packages/typed-url-pattern/src/typedURLPattern.ts index 01ec031..2295c04 100644 --- a/packages/typed-url-pattern/src/typedURLPattern.ts +++ b/packages/typed-url-pattern/src/typedURLPattern.ts @@ -5,6 +5,8 @@ export class TypedURLPattern< T extends StandardSchemaV1, U extends StandardSchemaV1, > { + static debug = false; + #pattern: URLPattern; #paramsSchema: T | undefined; #searchParamsSchema: U | undefined; @@ -38,23 +40,30 @@ export class TypedURLPattern< throw new TypeError("URL Pattern validation must be synchronous"); } - if (result.issues) return null; + if (result.issues) { + if (TypedURLPattern.debug) console.log(result.issues); + return null; + } parsedParams = result.value; } - const searchParams = match?.search; + const search = match?.search.input; const searchParamsSchema = this.#searchParamsSchema; let parsedSearchParams; - if (searchParamsSchema && searchParams) { + if (searchParamsSchema && search) { + const searchParams = Object.fromEntries(new URLSearchParams(search)); const result = searchParamsSchema["~standard"].validate(searchParams); if (result instanceof Promise) { throw new TypeError("URL Pattern validation must be synchronous"); } - if (result.issues) return null; + if (result.issues) { + if (TypedURLPattern.debug) console.log(result.issues); + return null; + } parsedSearchParams = result.value; } From a2a72aa1dbbef48aedb758a318f7aa4733292261 Mon Sep 17 00:00:00 2001 From: fcrozatier Date: Sun, 16 Nov 2025 18:31:42 +0100 Subject: [PATCH 04/46] add static baseURL prop --- .../typed-url-pattern/src/typedURLPattern.ts | 25 ++++++++++++++----- 1 file changed, 19 insertions(+), 6 deletions(-) diff --git a/packages/typed-url-pattern/src/typedURLPattern.ts b/packages/typed-url-pattern/src/typedURLPattern.ts index 2295c04..7ff0128 100644 --- a/packages/typed-url-pattern/src/typedURLPattern.ts +++ b/packages/typed-url-pattern/src/typedURLPattern.ts @@ -6,6 +6,7 @@ export class TypedURLPattern< U extends StandardSchemaV1, > { static debug = false; + static baseURL = ""; #pattern: URLPattern; #paramsSchema: T | undefined; @@ -19,13 +20,17 @@ export class TypedURLPattern< searchParams?: U; }, ) { - this.#pattern = new URLPattern(input); + const init: URLPatternInit = typeof input === "string" + ? { pathname: input, baseURL: TypedURLPattern.baseURL } + : { ...input, baseURL: input.baseURL ?? TypedURLPattern.baseURL }; + + this.pattern = new URLPattern(init); this.#paramsSchema = schema?.params; this.#searchParamsSchema = schema?.searchParams; } match(input: URLPatternInput, baseURL?: string) { - const match = this.#pattern.exec(input, baseURL); + const match = this.pattern.exec(input, baseURL ?? TypedURLPattern.baseURL); if (!match) return null; const params = match?.pathname.groups; @@ -37,11 +42,15 @@ export class TypedURLPattern< const result = paramsSchema["~standard"].validate(params); if (result instanceof Promise) { - throw new TypeError("URL Pattern validation must be synchronous"); + throw new TypeError( + "[TypedURLPattern]: URL Pattern validation must be synchronous", + ); } if (result.issues) { - if (TypedURLPattern.debug) console.log(result.issues); + if (TypedURLPattern.debug) { + console.log("[TypedURLPattern]:", result.issues); + } return null; } parsedParams = result.value; @@ -57,11 +66,15 @@ export class TypedURLPattern< const result = searchParamsSchema["~standard"].validate(searchParams); if (result instanceof Promise) { - throw new TypeError("URL Pattern validation must be synchronous"); + throw new TypeError( + "[TypedURLPattern]: URL Pattern validation must be synchronous", + ); } if (result.issues) { - if (TypedURLPattern.debug) console.log(result.issues); + if (TypedURLPattern.debug) { + console.log("[TypedURLPattern]", result.issues); + } return null; } parsedSearchParams = result.value; From fa91a41176473d2851826b161544a7f1832365a1 Mon Sep 17 00:00:00 2001 From: fcrozatier Date: Mon, 17 Nov 2025 13:53:17 +0100 Subject: [PATCH 05/46] make pattern public --- packages/typed-url-pattern/src/typedURLPattern.ts | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/packages/typed-url-pattern/src/typedURLPattern.ts b/packages/typed-url-pattern/src/typedURLPattern.ts index 7ff0128..23c46a3 100644 --- a/packages/typed-url-pattern/src/typedURLPattern.ts +++ b/packages/typed-url-pattern/src/typedURLPattern.ts @@ -8,10 +8,15 @@ export class TypedURLPattern< static debug = false; static baseURL = ""; - #pattern: URLPattern; #paramsSchema: T | undefined; #searchParamsSchema: U | undefined; + /** + * Pattern syntax + * https://developer.mozilla.org/en-US/docs/Web/API/URL_Pattern_API#pattern_syntax + */ + pattern: URLPattern; + // Provide a default baseURL constructor( input: URLPatternInput, @@ -102,7 +107,7 @@ export class TypedURLPattern< hash?: string; }, ): string { - const pattern = this.#pattern; + const pattern = this.pattern; const protocol = pattern.protocol ? pattern.protocol + "://" : ""; const username = pattern.username ? pattern.username + "@" : ""; From 39d1bf4157b081255204627cdb0b50b7c93b7368 Mon Sep 17 00:00:00 2001 From: fcrozatier Date: Mon, 17 Nov 2025 13:53:23 +0100 Subject: [PATCH 06/46] add index --- packages/typed-url-pattern/src/index.ts | 0 1 file changed, 0 insertions(+), 0 deletions(-) create mode 100644 packages/typed-url-pattern/src/index.ts diff --git a/packages/typed-url-pattern/src/index.ts b/packages/typed-url-pattern/src/index.ts new file mode 100644 index 0000000..e69de29 From ef64627a3f5633ed6e790677dfb2fc98a27813aa Mon Sep 17 00:00:00 2001 From: fcrozatier Date: Mon, 17 Nov 2025 13:53:31 +0100 Subject: [PATCH 07/46] add tests --- .../src/typedURLPattern.test.ts | 370 ++++++++++++++++++ 1 file changed, 370 insertions(+) create mode 100644 packages/typed-url-pattern/src/typedURLPattern.test.ts diff --git a/packages/typed-url-pattern/src/typedURLPattern.test.ts b/packages/typed-url-pattern/src/typedURLPattern.test.ts new file mode 100644 index 0000000..4cc586d --- /dev/null +++ b/packages/typed-url-pattern/src/typedURLPattern.test.ts @@ -0,0 +1,370 @@ +import { assert, assertEquals, assertExists, assertThrows } from "@std/assert"; +import { TypedURLPattern } from "./typedURLPattern.ts"; +import * as z from "zod"; + +TypedURLPattern.debug = true; +TypedURLPattern.baseURL = "https://example.com"; + +// Pathname + +Deno.test("matches a simple pathname", () => { + const pattern = new TypedURLPattern({ pathname: "/users" }); + const match = pattern.match("/users"); + + assertExists(match); + assertEquals(match.pathname, "/users"); +}); + +Deno.test("returns null for non-matching paths", () => { + const pattern = new TypedURLPattern({ pathname: "/users" }); + const match = pattern.match("/posts"); + + assertEquals(match, null); +}); + +// Params + +Deno.test("extracts params", () => { + const pattern = new TypedURLPattern( + { pathname: "/blog/:year(\\d+)/:title" }, + { params: z.object({ year: z.coerce.number(), title: z.string() }) }, + ); + + const match = pattern.match("/blog/2025/my-post"); + + assertExists(match); + assertEquals(match.params, { year: 2025, title: "my-post" }); +}); + +Deno.test("returns null if params don't match", () => { + const pattern = new TypedURLPattern( + { pathname: "/blog/:year(\\d+)" }, + { params: z.object({ year: z.coerce.number() }) }, + ); + + const match = pattern.match("/blog/first"); + + assertEquals(match, null); +}); + +Deno.test("extracts unnamed params", () => { + const pattern = new TypedURLPattern( + { pathname: "/images/*.png" }, + { params: z.object({ "0": z.string() }) }, + ); + + const match = pattern.match("/images/cake.png"); + + assertExists(match); + assertEquals(match.params, { 0: "cake" }); +}); + +// Search Params + +Deno.test("extracts search params", () => { + const pattern = new TypedURLPattern({ + pathname: "/items", + search: "?view=:mode", + }, { + searchParams: z.object({ view: z.enum(["full", "small"]) }), + }); + + const match = pattern.match("/items?view=full"); + + assertExists(match); + assertEquals(match.searchParams.view, "full"); +}); + +Deno.test("fails if search params don't match", () => { + const pattern = new TypedURLPattern({ + pathname: "/items", + search: "?view=:mode", + }, { + searchParams: z.object({ view: z.enum(["full", "small"]) }), + }); + + const match = pattern.match("/items?foo=bar"); + + assertEquals(match, null); +}); + +Deno.test("strips unspecified search params", () => { + const pattern = new TypedURLPattern({ + pathname: "/items", + search: "?view=:mode", + }, { + searchParams: z.object({ view: z.enum(["full", "small"]) }), + }); + + const match = pattern.match("/items?view=full&utm=foo"); + + assertExists(match); + assertEquals(match.searchParams, { view: "full" }); +}); + +Deno.test("allows optional search params", () => { + const pattern = new TypedURLPattern( + { pathname: "/items", search: "*" }, + { + searchParams: z.looseObject({ view: z.enum(["full", "small"]) }), + }, + ); + + const match = pattern.match("/items?view=full&utm=foo"); + + assertExists(match); + + // utm passes through, we could use `z.object` or `z.strictObject` to prevent that + assertEquals(match.searchParams, { view: "full", utm: "foo" }); +}); + +// Hash + +// Deno.test("extracts hash params", () => { +// const pattern = new TypedURLPattern({ +// pathname: "/docs", +// hash: ":section", +// }); + +// const match = pattern.match("/docs#section=intro", baseURL); + +// assertExists(match); +// assertEquals(match.hash.id, "intro"); +// }); + +// // ─────────────────────────────────────────────── +// // hash parameters +// // ─────────────────────────────────────────────── + +// describe("hash parameter matching", () => { +// +// }); + +// // ─────────────────────────────────────────────── +// // href() inverse URL construction +// // ─────────────────────────────────────────────── + +// describe("href() URL generation", () => { +// Deno.test("generates basic URLs", () => { +// const route = new TypedURLPattern({ +// pathname: "/users/:id", +// }); + +// const url = route.href({ +// pathname: { id: "55" }, +// }); + +// assertEquals(url, "/users/55"); +// }); + +// Deno.test("generates URLs with search params", () => { +// const route = new TypedURLPattern({ +// pathname: "/users/:id", +// search: "?tab=:tab&sort=:sort", +// }); + +// const url = route.href({ +// pathname: { id: "8" }, +// search: { tab: "info", sort: "asc" }, +// }); + +// assertEquals(url, "/users/8?tab=info&sort=asc"); +// }); + +// Deno.test("generates URLs with hash params", () => { +// const route = new TypedURLPattern({ +// pathname: "/docs/:page", +// hash: "#section=:section", +// }); + +// const url = route.href({ +// pathname: { page: "intro" }, +// hash: { section: "install" }, +// }); + +// assertEquals(url, "/docs/intro#section=install"); +// }); + +// Deno.test("URL-encodes parameters", () => { +// const route = new TypedURLPattern({ +// pathname: "/u/:name", +// }); + +// const url = route.href({ +// pathname: { name: "John Doe" }, +// }); + +// assertEquals(url, "/u/John%20Doe"); +// }); + +// Deno.test("handles missing optional search/hash sections gracefully", () => { +// const route = new TypedURLPattern({ +// pathname: "/page/:id", +// search: "?q=:q", +// hash: "#x=:x", +// }); + +// const url = route.href({ +// pathname: { id: "12" }, +// }); + +// // search/hash omitted entirely +// assertEquals(url, "/page/12"); +// }); +// }); + +// // ─────────────────────────────────────────────── +// // Edge cases and errors +// // ─────────────────────────────────────────────── + +// describe("edge cases", () => { +// Deno.test("throws if pathname param is missing", () => { +// const route = new TypedURLPattern({ +// pathname: "/test/:id", +// }); + +// // @ts-expect-error — missing id +// assertThrows(() => route.href({ pathname: {} as any })); +// }); + +// Deno.test("ignores unused search params", () => { +// const route = new TypedURLPattern({ +// pathname: "/x/:id", +// search: "?mode=:mode", +// }); + +// const url = route.href({ +// pathname: { id: "1" }, +// search: { mode: "full", extra: "zzz" } as any, +// }); + +// assertEquals(url, "/x/1?mode=full"); // `extra` ignored +// }); + +// Deno.test("matches full URL objects", () => { +// const route = new TypedURLPattern({ +// pathname: "/a/:b", +// }); + +// const match = route.exec(new URL("https://site.com/a/xyz")); +// assertExists(match); +// assertEquals(match.pathname.b, "xyz"); +// }); +// }); +// }); + +// Deno.test("makes absolute hrefs when no host is provided", () => { +// const pattern = new TypedURLPattern<{ params: { id: number } }>({ +// pathname: "products/:id", +// }); + +// assertEquals(pattern.href({ params: { id: 1 } }), "/products/1"); +// }); + +// Deno.test("substitutes * for unnamed wildcards in variants", () => { +// const pattern = createHrefBuilder(); +// assertEquals( +// href("/files/*.jpg", { "*": "cat/dog" }), +// "/files/cat/dog.jpg", +// ); +// assertEquals( +// href("*/files/*.jpg", { "*": "cat/dog" }), +// "/cat/dog/files/cat/dog.jpg", +// ); +// }); + +// Deno.test("fills in params", () => { +// const pattern = createHrefBuilder(); + +// assertEquals(href("products/:id", { id: "1" }), "/products/1"); +// // Number is coerced to string +// assertEquals(href("products/:id", { id: 1 }), "/products/1"); + +// assertEquals( +// href("images/*path.png", { path: "images/hero" }), +// "/images/images/hero.png", +// ); +// assertEquals( +// href("images/*.png", { "*": "images/hero" }), +// "/images/images/hero.png", +// ); + +// // Include optionals by default +// assertEquals(href("products(.md)"), "/products.md"); + +// // Omit optionals with undefined/missing params +// assertEquals(href("products/:id(.:ext)", { id: "1" }), "/products/1"); +// assertEquals(href("products(/:id)", {}), "/products"); +// assertEquals(href("products(/:id)", null), "/products"); +// }); + +// Deno.test("requires a valid pattern", () => { +// const pattern = createHrefBuilder<"products(/:id)">(); +// // @ts-expect-error invalid pattern +// assertEquals(href("does-not-exist"), "/does-not-exist"); +// }); + +// Deno.test("throws when required params are missing", () => { +// const pattern = createHrefBuilder(); +// // @ts-expect-error missing required "id" param +// assert.throws(() => href("products/:id", {}), new MissingParamError("id")); +// // @ts-expect-error missing required "category" param +// assert.throws( +// () => href("*category/products", {}), +// new MissingParamError("category"), +// ); +// }); + +// Deno.test("fills in search params", () => { +// const pattern = createHrefBuilder(); + +// assertEquals( +// href("products/:id", { id: "1" }, { sort: "asc" }), +// "/products/1?sort=asc", +// ); + +// assertEquals( +// href("products/:id", { id: "1" }, { sort: "asc", limit: "10" }), +// "/products/1?sort=asc&limit=10", +// ); + +// assertEquals( +// href("products/:id", { id: "1" }, "sort=asc&limit=10"), +// "/products/1?sort=asc&limit=10", +// ); + +// assertEquals( +// href( +// "products/:id", +// { id: "1" }, +// new URLSearchParams("sort=asc&limit=10"), +// ), +// "/products/1?sort=asc&limit=10", +// ); + +// assertEquals( +// href("products/:id", { id: "1" }, [ +// ["sort", "asc"], +// ["limit", "10"], +// ]), +// "/products/1?sort=asc&limit=10", +// ); + +// // Preserves existing search params exactly as provided +// assertEquals( +// href("products/:id?sort=asc&limit=", { id: "1" }), +// "/products/1?sort=asc&limit=", +// ); + +// // Swaps out a new value for an existing param +// assertEquals( +// href("https://remix.run/search?q=remix", null, { q: "angular" }), +// "https://remix.run/search?q=angular", +// ); + +// // Completely replaces existing search params +// assertEquals( +// href("https://remix.run/search?q=remix", null, { some: "thing" }), +// "https://remix.run/search?some=thing", +// ); +// }); From ac83033521b928ac3927300eaec808d129f9d61d Mon Sep 17 00:00:00 2001 From: fcrozatier Date: Mon, 17 Nov 2025 14:12:21 +0100 Subject: [PATCH 08/46] add readme --- packages/typed-url-pattern/README.md | 89 ++++++++++++++++++++++++++++ 1 file changed, 89 insertions(+) create mode 100644 packages/typed-url-pattern/README.md diff --git a/packages/typed-url-pattern/README.md b/packages/typed-url-pattern/README.md new file mode 100644 index 0000000..c913729 --- /dev/null +++ b/packages/typed-url-pattern/README.md @@ -0,0 +1,89 @@ + + +```ts +import { TypedURLPattern } from '@f-stack/typed-url-pattern'; +import * as z from "zod"; + +const userRoute = new TypedURLPattern( + { pathname: "/users/:id" }, + { params: z.object({ id: z.number() }) } +); + +userRoute.href({ params: { id: 123 } }); +``` + +# TypedURLPattern + +A tiny TypeScript wrapper around the Web's native [URLPattern](https://developer.mozilla.org/en-US/docs/Web/API/URLPattern) API providing: + +- Type-safe params for your routes and API endpoints +- [Standard Schema](https://standardschema.dev/) validation +- Standard `URLPattern` syntax that's here to stay (just use the Platform) +- A typed `href()` inverse (build URLs with type-safe params) +- Zero dependencies and framework-agnostic +- Works in Deno, Bun, Node, and Cloudflare Workers + +## Install + +## Example patterns + +- **Typed parameter extraction** + +```ts +const match = route.exec("/users/42?tab=info#section=photos"); + +match.pathname.id; // string +match.search.tab; // string +match.hash.section; // string +``` + +- **Typed inverse: build URLs from params** + +```ts +route.href({ + pathname: { id: "42" }, + search: { tab: "info" }, + hash: { section: "photos" }, +}); +// "/users/42?tab=info#section=photos" +``` + +- **Zero overhead** — only a thin and fully typed layer over URLPattern. +- **Full pattern support** for pathname, search, and hash segments. +- **Great for routers**, service workers, runtime routing, API request matching, etc. + +## Quick Start + +1. Create a typed pattern +```ts +const route = new TypedURLPattern({ + pathname: "/users/:id", + search: "?tab=:tab", + hash: "#section=:section", +}); +``` + +2. Extract typed params +```ts +const match = route.exec("https://example.com/users/123?tab=info#section=photos"); + +if (match) { + match.pathname.id; // "123" + match.search.tab; // "info" + match.hash.section; // "photos" +} +``` + +3. Generate URLs (inverse of exec()) +```ts +const url = route.href({ + pathname: { id: "123" }, + search: { tab: "info" }, + hash: { section: "photos" }, +}); + +console.log(url); +// "/users/123?tab=info§ion=photos" +``` + +## API From f67d1b97446d8f35d8e62599d2e7cfbb0baf2c99 Mon Sep 17 00:00:00 2001 From: fcrozatier Date: Mon, 17 Nov 2025 14:12:30 +0100 Subject: [PATCH 09/46] entrypoint --- packages/typed-url-pattern/deno.json | 2 +- packages/typed-url-pattern/src/index.ts | 0 2 files changed, 1 insertion(+), 1 deletion(-) delete mode 100644 packages/typed-url-pattern/src/index.ts diff --git a/packages/typed-url-pattern/deno.json b/packages/typed-url-pattern/deno.json index 7e397aa..abe84e7 100644 --- a/packages/typed-url-pattern/deno.json +++ b/packages/typed-url-pattern/deno.json @@ -3,7 +3,7 @@ "version": "0.1.0", "license": "MIT", "exports": { - ".": "./src/index.ts" + ".": "./src/typedURLPattern.ts" }, "imports": { "@standard-schema/spec": "jsr:@standard-schema/spec@^1.0.0", diff --git a/packages/typed-url-pattern/src/index.ts b/packages/typed-url-pattern/src/index.ts deleted file mode 100644 index e69de29..0000000 From 3f59b71d2ddf5c048147ae395fe1d734a9675e80 Mon Sep 17 00:00:00 2001 From: fcrozatier Date: Thu, 20 Nov 2025 10:06:19 +0100 Subject: [PATCH 10/46] reorganize tests --- .../src/typedURLPattern.test.ts | 51 ++++++------------- 1 file changed, 16 insertions(+), 35 deletions(-) diff --git a/packages/typed-url-pattern/src/typedURLPattern.test.ts b/packages/typed-url-pattern/src/typedURLPattern.test.ts index 4cc586d..1cc87a2 100644 --- a/packages/typed-url-pattern/src/typedURLPattern.test.ts +++ b/packages/typed-url-pattern/src/typedURLPattern.test.ts @@ -5,26 +5,9 @@ import * as z from "zod"; TypedURLPattern.debug = true; TypedURLPattern.baseURL = "https://example.com"; -// Pathname - -Deno.test("matches a simple pathname", () => { - const pattern = new TypedURLPattern({ pathname: "/users" }); - const match = pattern.match("/users"); - - assertExists(match); - assertEquals(match.pathname, "/users"); -}); - -Deno.test("returns null for non-matching paths", () => { - const pattern = new TypedURLPattern({ pathname: "/users" }); - const match = pattern.match("/posts"); - - assertEquals(match, null); -}); - // Params -Deno.test("extracts params", () => { +Deno.test("type-safe params", () => { const pattern = new TypedURLPattern( { pathname: "/blog/:year(\\d+)/:title" }, { params: z.object({ year: z.coerce.number(), title: z.string() }) }, @@ -36,32 +19,32 @@ Deno.test("extracts params", () => { assertEquals(match.params, { year: 2025, title: "my-post" }); }); -Deno.test("returns null if params don't match", () => { +Deno.test("type-safe unnamed params", () => { const pattern = new TypedURLPattern( - { pathname: "/blog/:year(\\d+)" }, - { params: z.object({ year: z.coerce.number() }) }, + { pathname: "/images/*.png" }, + { params: z.object({ "0": z.string() }) }, ); - const match = pattern.match("/blog/first"); + const match = pattern.match("/images/cake.png"); - assertEquals(match, null); + assertExists(match); + assertEquals(match.params, { 0: "cake" }); }); -Deno.test("extracts unnamed params", () => { +Deno.test("params validation", () => { const pattern = new TypedURLPattern( - { pathname: "/images/*.png" }, - { params: z.object({ "0": z.string() }) }, + { pathname: "/blog/:year(\\d+)" }, + { params: z.object({ year: z.coerce.number() }) }, ); - const match = pattern.match("/images/cake.png"); + const match = pattern.match("/blog/abc"); - assertExists(match); - assertEquals(match.params, { 0: "cake" }); + assertEquals(match, null); }); // Search Params -Deno.test("extracts search params", () => { +Deno.test("type-safe search params", () => { const pattern = new TypedURLPattern({ pathname: "/items", search: "?view=:mode", @@ -75,7 +58,7 @@ Deno.test("extracts search params", () => { assertEquals(match.searchParams.view, "full"); }); -Deno.test("fails if search params don't match", () => { +Deno.test("search params validation", () => { const pattern = new TypedURLPattern({ pathname: "/items", search: "?view=:mode", @@ -88,7 +71,7 @@ Deno.test("fails if search params don't match", () => { assertEquals(match, null); }); -Deno.test("strips unspecified search params", () => { +Deno.test("strict search params validation", () => { const pattern = new TypedURLPattern({ pathname: "/items", search: "?view=:mode", @@ -102,7 +85,7 @@ Deno.test("strips unspecified search params", () => { assertEquals(match.searchParams, { view: "full" }); }); -Deno.test("allows optional search params", () => { +Deno.test("loose search params validation", () => { const pattern = new TypedURLPattern( { pathname: "/items", search: "*" }, { @@ -113,8 +96,6 @@ Deno.test("allows optional search params", () => { const match = pattern.match("/items?view=full&utm=foo"); assertExists(match); - - // utm passes through, we could use `z.object` or `z.strictObject` to prevent that assertEquals(match.searchParams, { view: "full", utm: "foo" }); }); From 3af158bb37fbca4824fca2b7f616a5af6de51de1 Mon Sep 17 00:00:00 2001 From: fcrozatier Date: Thu, 20 Nov 2025 10:06:29 +0100 Subject: [PATCH 11/46] readme --- packages/typed-url-pattern/README.md | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/packages/typed-url-pattern/README.md b/packages/typed-url-pattern/README.md index c913729..6117863 100644 --- a/packages/typed-url-pattern/README.md +++ b/packages/typed-url-pattern/README.md @@ -16,18 +16,16 @@ userRoute.href({ params: { id: 123 } }); A tiny TypeScript wrapper around the Web's native [URLPattern](https://developer.mozilla.org/en-US/docs/Web/API/URLPattern) API providing: -- Type-safe params for your routes and API endpoints -- [Standard Schema](https://standardschema.dev/) validation -- Standard `URLPattern` syntax that's here to stay (just use the Platform) +- **Type-safe params** for your routes and API endpoints +- **Params validation** with [Standard Schema](https://standardschema.dev/) +- **Standard syntax**: it's just `URLPattern` under the hood (use the Platform) - A typed `href()` inverse (build URLs with type-safe params) -- Zero dependencies and framework-agnostic -- Works in Deno, Bun, Node, and Cloudflare Workers ## Install ## Example patterns -- **Typed parameter extraction** +- **Typed parameters** ```ts const match = route.exec("/users/42?tab=info#section=photos"); From 8d6253b294b21c3cde7e62e8e9590a36f1a56fd0 Mon Sep 17 00:00:00 2001 From: fcrozatier Date: Thu, 20 Nov 2025 10:24:30 +0100 Subject: [PATCH 12/46] compose and expose url pattern result --- packages/typed-url-pattern/src/typedURLPattern.ts | 14 +++----------- 1 file changed, 3 insertions(+), 11 deletions(-) diff --git a/packages/typed-url-pattern/src/typedURLPattern.ts b/packages/typed-url-pattern/src/typedURLPattern.ts index 23c46a3..38120ec 100644 --- a/packages/typed-url-pattern/src/typedURLPattern.ts +++ b/packages/typed-url-pattern/src/typedURLPattern.ts @@ -8,8 +8,8 @@ export class TypedURLPattern< static debug = false; static baseURL = ""; - #paramsSchema: T | undefined; - #searchParamsSchema: U | undefined; + #paramsSchema?: T | undefined; + #searchParamsSchema?: U | undefined; /** * Pattern syntax @@ -86,17 +86,9 @@ export class TypedURLPattern< } return { - protocol: match.protocol.input, - username: match.username.input, - password: match.password.input, - hostname: match.hostname.input, - port: match.port.input, - pathname: match.pathname.input, + patternResult: match, params: parsedParams as StandardSchemaV1.InferOutput, - search: match?.search.input, - searchGroups: match?.search.groups, searchParams: parsedSearchParams as StandardSchemaV1.InferOutput, - hash: match?.search.input, }; } From df731a5d2b0a09bc30980b102759f9d9e2c8b0b9 Mon Sep 17 00:00:00 2001 From: fcrozatier Date: Thu, 20 Nov 2025 11:20:26 +0100 Subject: [PATCH 13/46] add findBaseURL helper --- packages/typed-url-pattern/src/utils.test.ts | 12 ++++++++++++ packages/typed-url-pattern/src/utils.ts | 7 +++++++ 2 files changed, 19 insertions(+) create mode 100644 packages/typed-url-pattern/src/utils.test.ts create mode 100644 packages/typed-url-pattern/src/utils.ts diff --git a/packages/typed-url-pattern/src/utils.test.ts b/packages/typed-url-pattern/src/utils.test.ts new file mode 100644 index 0000000..e859094 --- /dev/null +++ b/packages/typed-url-pattern/src/utils.test.ts @@ -0,0 +1,12 @@ +import { assertEquals } from "@std/assert/equals"; +import { findBaseURL } from "./utils.ts"; + +Deno.test.only("findBaseURL", () => { + assertEquals(findBaseURL("http://a.b/c"), "http://a.b"); + assertEquals(findBaseURL("http://a.b/"), "http://a.b"); + assertEquals(findBaseURL("http://a.b"), "http://a.b"); + + assertEquals(findBaseURL("https://a.b/c"), "https://a.b"); + assertEquals(findBaseURL("https://a.b/"), "https://a.b"); + assertEquals(findBaseURL("https://a.b"), "https://a.b"); +}); diff --git a/packages/typed-url-pattern/src/utils.ts b/packages/typed-url-pattern/src/utils.ts new file mode 100644 index 0000000..0a3bbbf --- /dev/null +++ b/packages/typed-url-pattern/src/utils.ts @@ -0,0 +1,7 @@ +/** + * Extracts the BaseURL from the input URL + */ +export function findBaseURL(input: string) { + const index = input.indexOf("/", 8); // look for the first / after https?:// + return index === -1 ? input : input.slice(0, index); +} From 70935236b8386d056268887384e7f1e877ff9966 Mon Sep 17 00:00:00 2001 From: fcrozatier Date: Thu, 20 Nov 2025 11:48:20 +0100 Subject: [PATCH 14/46] test href --- .../src/typedURLPattern.test.ts | 96 ++++++++----------- .../typed-url-pattern/src/typedURLPattern.ts | 49 +++++++--- 2 files changed, 78 insertions(+), 67 deletions(-) diff --git a/packages/typed-url-pattern/src/typedURLPattern.test.ts b/packages/typed-url-pattern/src/typedURLPattern.test.ts index 1cc87a2..cbc15fe 100644 --- a/packages/typed-url-pattern/src/typedURLPattern.test.ts +++ b/packages/typed-url-pattern/src/typedURLPattern.test.ts @@ -2,8 +2,10 @@ import { assert, assertEquals, assertExists, assertThrows } from "@std/assert"; import { TypedURLPattern } from "./typedURLPattern.ts"; import * as z from "zod"; +const BASE_URL = "https://example.com"; + TypedURLPattern.debug = true; -TypedURLPattern.baseURL = "https://example.com"; +TypedURLPattern.baseURL = BASE_URL; // Params @@ -99,72 +101,56 @@ Deno.test("loose search params validation", () => { assertEquals(match.searchParams, { view: "full", utm: "foo" }); }); -// Hash - -// Deno.test("extracts hash params", () => { -// const pattern = new TypedURLPattern({ -// pathname: "/docs", -// hash: ":section", -// }); - -// const match = pattern.match("/docs#section=intro", baseURL); - -// assertExists(match); -// assertEquals(match.hash.id, "intro"); -// }); +// href -// // ─────────────────────────────────────────────── -// // hash parameters -// // ─────────────────────────────────────────────── +Deno.test("href() type-safe params", () => { + const route = new TypedURLPattern( + { pathname: "/users/:id" }, + { params: z.object({ id: z.string() }) }, + ); -// describe("hash parameter matching", () => { -// -// }); + const url = route.href({ + params: { id: "55" }, + }); -// // ─────────────────────────────────────────────── -// // href() inverse URL construction -// // ─────────────────────────────────────────────── + assertEquals(url, `${BASE_URL}/users/55`); +}); -// describe("href() URL generation", () => { -// Deno.test("generates basic URLs", () => { -// const route = new TypedURLPattern({ -// pathname: "/users/:id", -// }); +Deno.test("href() type-safe search params", () => { + const route = new TypedURLPattern({ + pathname: "/search", + search: "?page=:page&sort=:sort", + }, { + searchParams: z.object({ page: z.number(), sort: z.enum(["asc", "desc"]) }), + }); -// const url = route.href({ -// pathname: { id: "55" }, -// }); + const url = route.href({ + searchParams: { page: 2, sort: "asc" }, + }); -// assertEquals(url, "/users/55"); -// }); + assertEquals(url, `${BASE_URL}/search?page=2&sort=asc`); +}); -// Deno.test("generates URLs with search params", () => { -// const route = new TypedURLPattern({ -// pathname: "/users/:id", -// search: "?tab=:tab&sort=:sort", -// }); +Deno.test("href() with hash", () => { + const route = new TypedURLPattern({ + pathname: "/docs", + }); -// const url = route.href({ -// pathname: { id: "8" }, -// search: { tab: "info", sort: "asc" }, -// }); + const url = route.href({ hash: { section: "install" } }); -// assertEquals(url, "/users/8?tab=info&sort=asc"); -// }); + assertEquals(url, `${BASE_URL}/docs#section=install`); +}); -// Deno.test("generates URLs with hash params", () => { -// const route = new TypedURLPattern({ -// pathname: "/docs/:page", -// hash: "#section=:section", -// }); +Deno.test("href() with hash object", () => { + const route = new TypedURLPattern({ + pathname: "/docs", + hash: "#section=:section", + }); -// const url = route.href({ -// pathname: { page: "intro" }, -// hash: { section: "install" }, -// }); + const url = route.href({ hash: { section: "install" } }); -// assertEquals(url, "/docs/intro#section=install"); -// }); + assertEquals(url, `${BASE_URL}/docs#section=install`); +}); // Deno.test("URL-encodes parameters", () => { // const route = new TypedURLPattern({ diff --git a/packages/typed-url-pattern/src/typedURLPattern.ts b/packages/typed-url-pattern/src/typedURLPattern.ts index 38120ec..ac8b1fa 100644 --- a/packages/typed-url-pattern/src/typedURLPattern.ts +++ b/packages/typed-url-pattern/src/typedURLPattern.ts @@ -1,5 +1,6 @@ import type { StandardSchemaV1 } from "@standard-schema/spec"; import { assert } from "@std/assert/assert"; +import { findBaseURL } from "./utils.ts"; export class TypedURLPattern< T extends StandardSchemaV1, @@ -11,6 +12,8 @@ export class TypedURLPattern< #paramsSchema?: T | undefined; #searchParamsSchema?: U | undefined; + baseURL = ""; + /** * Pattern syntax * https://developer.mozilla.org/en-US/docs/Web/API/URL_Pattern_API#pattern_syntax @@ -25,9 +28,20 @@ export class TypedURLPattern< searchParams?: U; }, ) { - const init: URLPatternInit = typeof input === "string" - ? { pathname: input, baseURL: TypedURLPattern.baseURL } - : { ...input, baseURL: input.baseURL ?? TypedURLPattern.baseURL }; + let baseURL = ""; + + if (typeof input === "string") { + // We need to figure out the baseURL from the input string + baseURL = findBaseURL(input); + } else { + baseURL = input.baseURL ?? TypedURLPattern.baseURL; + } + + this.baseURL = baseURL; + + const init: URLPatternInput = typeof input === "string" + ? input + : { ...input, baseURL }; this.pattern = new URLPattern(init); this.#paramsSchema = schema?.params; @@ -96,15 +110,11 @@ export class TypedURLPattern< options: { params?: StandardSchemaV1.InferInput; searchParams?: StandardSchemaV1.InferInput; - hash?: string; + hash?: string | Record; }, ): string { const pattern = this.pattern; - const protocol = pattern.protocol ? pattern.protocol + "://" : ""; - const username = pattern.username ? pattern.username + "@" : ""; - const port = pattern.port ? ":" + pattern.port : ""; - let pathname = pattern.pathname; if (options.params) { @@ -115,7 +125,7 @@ export class TypedURLPattern< typeof value === "boolean", "Params must be strings, numbers or booleans", ); - pathname = pathname.replace(key, encodeURIComponent(value)); + pathname = pathname.replace(":" + key, encodeURIComponent(value)); } } @@ -138,9 +148,24 @@ export class TypedURLPattern< } } - const hash = options.hash ? "#" + options.hash : ""; + let hash = typeof options.hash === "string" ? "#" + options.hash : ""; + + if (typeof options.hash === "object") { + let patternHash = this.pattern.hash; + + for (const [key, value] of Object.entries(options.hash)) { + assert( + typeof value === "string" || + typeof value === "number" || + typeof value === "boolean", + "Hash must be strings, numbers or booleans", + ); + patternHash = patternHash.replace(":" + key, encodeURIComponent(value)); + } + + hash = "#" + patternHash; + } - return protocol + username + pattern.hostname + port + pathname + search + - hash; + return this.baseURL + pathname + search + hash; } } From 0b01354d9180dee2017aa1b4ef27b315ad699aa2 Mon Sep 17 00:00:00 2001 From: fcrozatier Date: Thu, 20 Nov 2025 12:14:20 +0100 Subject: [PATCH 15/46] remove only test --- packages/typed-url-pattern/src/utils.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/typed-url-pattern/src/utils.test.ts b/packages/typed-url-pattern/src/utils.test.ts index e859094..ffa13da 100644 --- a/packages/typed-url-pattern/src/utils.test.ts +++ b/packages/typed-url-pattern/src/utils.test.ts @@ -1,7 +1,7 @@ import { assertEquals } from "@std/assert/equals"; import { findBaseURL } from "./utils.ts"; -Deno.test.only("findBaseURL", () => { +Deno.test("findBaseURL", () => { assertEquals(findBaseURL("http://a.b/c"), "http://a.b"); assertEquals(findBaseURL("http://a.b/"), "http://a.b"); assertEquals(findBaseURL("http://a.b"), "http://a.b"); From e020bc67fb0f902b7b8c6dc05ed8125f5f578d18 Mon Sep 17 00:00:00 2001 From: fcrozatier Date: Thu, 20 Nov 2025 12:14:47 +0100 Subject: [PATCH 16/46] type safe hash --- .../src/typedURLPattern.test.ts | 36 +++++++------ .../typed-url-pattern/src/typedURLPattern.ts | 50 +++++++++++-------- 2 files changed, 52 insertions(+), 34 deletions(-) diff --git a/packages/typed-url-pattern/src/typedURLPattern.test.ts b/packages/typed-url-pattern/src/typedURLPattern.test.ts index cbc15fe..6db7ab4 100644 --- a/packages/typed-url-pattern/src/typedURLPattern.test.ts +++ b/packages/typed-url-pattern/src/typedURLPattern.test.ts @@ -101,6 +101,22 @@ Deno.test("loose search params validation", () => { assertEquals(match.searchParams, { view: "full", utm: "foo" }); }); +// hash + +Deno.test("type-safe hash", () => { + const pattern = new TypedURLPattern({ + pathname: "/blog", + hash: ":section", + }, { + hash: z.enum(["intro", "outro"]), + }); + + const match = pattern.match("/blog#intro"); + + assertExists(match); + assertEquals(match.hash, "intro"); +}); + // href Deno.test("href() type-safe params", () => { @@ -133,23 +149,15 @@ Deno.test("href() type-safe search params", () => { Deno.test("href() with hash", () => { const route = new TypedURLPattern({ - pathname: "/docs", - }); - - const url = route.href({ hash: { section: "install" } }); - - assertEquals(url, `${BASE_URL}/docs#section=install`); -}); - -Deno.test("href() with hash object", () => { - const route = new TypedURLPattern({ - pathname: "/docs", - hash: "#section=:section", + pathname: "/blog", + hash: ":section", + }, { + hash: z.enum(["intro", "outro"]), }); - const url = route.href({ hash: { section: "install" } }); + const url = route.href({ hash: "intro" }); - assertEquals(url, `${BASE_URL}/docs#section=install`); + assertEquals(url, `${BASE_URL}/blog#intro`); }); // Deno.test("URL-encodes parameters", () => { diff --git a/packages/typed-url-pattern/src/typedURLPattern.ts b/packages/typed-url-pattern/src/typedURLPattern.ts index ac8b1fa..1a5424a 100644 --- a/packages/typed-url-pattern/src/typedURLPattern.ts +++ b/packages/typed-url-pattern/src/typedURLPattern.ts @@ -5,12 +5,14 @@ import { findBaseURL } from "./utils.ts"; export class TypedURLPattern< T extends StandardSchemaV1, U extends StandardSchemaV1, + V extends StandardSchemaV1, > { static debug = false; static baseURL = ""; #paramsSchema?: T | undefined; #searchParamsSchema?: U | undefined; + #hashSchema?: V | undefined; baseURL = ""; @@ -26,6 +28,7 @@ export class TypedURLPattern< schema?: { params?: T; searchParams?: U; + hash?: V; }, ) { let baseURL = ""; @@ -46,6 +49,7 @@ export class TypedURLPattern< this.pattern = new URLPattern(init); this.#paramsSchema = schema?.params; this.#searchParamsSchema = schema?.searchParams; + this.#hashSchema = schema?.hash; } match(input: URLPatternInput, baseURL?: string) { @@ -57,7 +61,7 @@ export class TypedURLPattern< let parsedParams; - if (paramsSchema && params) { + if (paramsSchema) { const result = paramsSchema["~standard"].validate(params); if (result instanceof Promise) { @@ -80,7 +84,7 @@ export class TypedURLPattern< let parsedSearchParams; - if (searchParamsSchema && search) { + if (searchParamsSchema) { const searchParams = Object.fromEntries(new URLSearchParams(search)); const result = searchParamsSchema["~standard"].validate(searchParams); @@ -99,10 +103,32 @@ export class TypedURLPattern< parsedSearchParams = result.value; } + const hashSchema = this.#hashSchema; + let parsedHash; + + if (hashSchema) { + const result = hashSchema["~standard"].validate(match?.hash.input); + + if (result instanceof Promise) { + throw new TypeError( + "[TypedURLPattern]: URL Pattern validation must be synchronous", + ); + } + + if (result.issues) { + if (TypedURLPattern.debug) { + console.log("[TypedURLPattern]:", result.issues); + } + return null; + } + parsedHash = result.value; + } + return { patternResult: match, params: parsedParams as StandardSchemaV1.InferOutput, searchParams: parsedSearchParams as StandardSchemaV1.InferOutput, + hash: parsedHash as StandardSchemaV1.InferOutput, }; } @@ -110,7 +136,7 @@ export class TypedURLPattern< options: { params?: StandardSchemaV1.InferInput; searchParams?: StandardSchemaV1.InferInput; - hash?: string | Record; + hash?: StandardSchemaV1.InferInput & string; }, ): string { const pattern = this.pattern; @@ -148,23 +174,7 @@ export class TypedURLPattern< } } - let hash = typeof options.hash === "string" ? "#" + options.hash : ""; - - if (typeof options.hash === "object") { - let patternHash = this.pattern.hash; - - for (const [key, value] of Object.entries(options.hash)) { - assert( - typeof value === "string" || - typeof value === "number" || - typeof value === "boolean", - "Hash must be strings, numbers or booleans", - ); - patternHash = patternHash.replace(":" + key, encodeURIComponent(value)); - } - - hash = "#" + patternHash; - } + const hash = typeof options.hash === "string" ? "#" + options.hash : ""; return this.baseURL + pathname + search + hash; } From c97cbc9650b1bdd42ac033bed8727f8d142bd815 Mon Sep 17 00:00:00 2001 From: fcrozatier Date: Thu, 20 Nov 2025 14:13:25 +0100 Subject: [PATCH 17/46] improve type safety --- .../src/typedURLPattern.test.ts | 70 ++++++++--------- .../typed-url-pattern/src/typedURLPattern.ts | 75 ++++++++++++++++--- 2 files changed, 101 insertions(+), 44 deletions(-) diff --git a/packages/typed-url-pattern/src/typedURLPattern.test.ts b/packages/typed-url-pattern/src/typedURLPattern.test.ts index 6db7ab4..aed1672 100644 --- a/packages/typed-url-pattern/src/typedURLPattern.test.ts +++ b/packages/typed-url-pattern/src/typedURLPattern.test.ts @@ -122,11 +122,11 @@ Deno.test("type-safe hash", () => { Deno.test("href() type-safe params", () => { const route = new TypedURLPattern( { pathname: "/users/:id" }, - { params: z.object({ id: z.string() }) }, + { params: z.object({ id: z.number() }) }, ); const url = route.href({ - params: { id: "55" }, + params: { id: 55 }, }); assertEquals(url, `${BASE_URL}/users/55`); @@ -160,47 +160,47 @@ Deno.test("href() with hash", () => { assertEquals(url, `${BASE_URL}/blog#intro`); }); -// Deno.test("URL-encodes parameters", () => { -// const route = new TypedURLPattern({ -// pathname: "/u/:name", -// }); +Deno.test("href() URL-encodes parameters", () => { + const route = new TypedURLPattern({ + pathname: "/u/:name", + }); -// const url = route.href({ -// pathname: { name: "John Doe" }, -// }); + const url = route.href({ + params: { name: "John Doe" }, + }); -// assertEquals(url, "/u/John%20Doe"); -// }); + assertEquals(url, `${BASE_URL}/u/John%20Doe`); +}); -// Deno.test("handles missing optional search/hash sections gracefully", () => { -// const route = new TypedURLPattern({ -// pathname: "/page/:id", -// search: "?q=:q", -// hash: "#x=:x", -// }); +Deno.test("href() handles optional sections gracefully", () => { + const route = new TypedURLPattern({ + pathname: "/page/:id", + search: "q=:q", + hash: ":x", + }); -// const url = route.href({ -// pathname: { id: "12" }, -// }); + const url = route.href({ + params: { id: "12" }, + }); -// // search/hash omitted entirely -// assertEquals(url, "/page/12"); -// }); -// }); + assertEquals(url, `${BASE_URL}/page/12`); +}); -// // ─────────────────────────────────────────────── -// // Edge cases and errors -// // ─────────────────────────────────────────────── +Deno.test.only("throws if pathname param is missing", () => { + const route = new TypedURLPattern({ + pathname: "/test", + baseURL: "http://example.com", + }); + route.href(); -// describe("edge cases", () => { -// Deno.test("throws if pathname param is missing", () => { -// const route = new TypedURLPattern({ -// pathname: "/test/:id", -// }); + // const route = new TypedURLPattern({ + // pathname: "/test/:id", + // }); -// // @ts-expect-error — missing id -// assertThrows(() => route.href({ pathname: {} as any })); -// }); + // assertThrows(() => route.href({})); +}); + +// describe("edge cases", () => { // Deno.test("ignores unused search params", () => { // const route = new TypedURLPattern({ diff --git a/packages/typed-url-pattern/src/typedURLPattern.ts b/packages/typed-url-pattern/src/typedURLPattern.ts index 1a5424a..ebb9b46 100644 --- a/packages/typed-url-pattern/src/typedURLPattern.ts +++ b/packages/typed-url-pattern/src/typedURLPattern.ts @@ -133,18 +133,61 @@ export class TypedURLPattern< } href( - options: { + ...args: + (unknown extends StandardSchemaV1.InferInput + ? unknown extends StandardSchemaV1.InferInput + ? unknown extends StandardSchemaV1.InferInput ? true : false + : false + : false) extends true ? [ + options?: Pretty< + & ConditionalOptional< + "params", + StandardSchemaV1.InferInput, + unknown extends StandardSchemaV1.InferInput ? true : false + > + & ConditionalOptional< + "searchParams", + StandardSchemaV1.InferInput, + unknown extends StandardSchemaV1.InferInput ? true : false + > + & ConditionalOptional< + "hash", + StandardSchemaV1.InferInput & string, + unknown extends StandardSchemaV1.InferInput ? true : false + > + >, + ] + : [ + options: Pretty< + & ConditionalOptional< + "params", + StandardSchemaV1.InferInput, + unknown extends StandardSchemaV1.InferInput ? true : false + > + & ConditionalOptional< + "searchParams", + StandardSchemaV1.InferInput, + unknown extends StandardSchemaV1.InferInput ? true : false + > + & ConditionalOptional< + "hash", + StandardSchemaV1.InferInput & string, + unknown extends StandardSchemaV1.InferInput ? true : false + > + >, + ] + ): string { + const { params, searchParams, hash } = args[0] as { params?: StandardSchemaV1.InferInput; searchParams?: StandardSchemaV1.InferInput; hash?: StandardSchemaV1.InferInput & string; - }, - ): string { + }; const pattern = this.pattern; let pathname = pattern.pathname; - if (options.params) { - for (const [key, value] of Object.entries(options.params)) { + if (params) { + for (const [key, value] of Object.entries(params)) { assert( typeof value === "string" || typeof value === "number" || @@ -157,9 +200,9 @@ export class TypedURLPattern< let search = ""; - if (options.searchParams) { + if (searchParams) { const entries: string[] = []; - for (const [key, value] of Object.entries(options.searchParams)) { + for (const [key, value] of Object.entries(searchParams)) { assert( typeof value === "string" || typeof value === "number" || @@ -174,8 +217,22 @@ export class TypedURLPattern< } } - const hash = typeof options.hash === "string" ? "#" + options.hash : ""; + const _hash = typeof hash === "string" ? "#" + hash : ""; - return this.baseURL + pathname + search + hash; + return this.baseURL + pathname + search + _hash; } } + +type Pretty = + & { + [K in keyof T]: T[K]; + } + & {}; + +type ConditionalOptional = + & { + [K in T as Condition extends true ? K : never]?: U; + } + & { + [K in T as Condition extends true ? never : K]: U; + }; From 42a61d312dd6a9a9811a9ec83c78b75b080db229 Mon Sep 17 00:00:00 2001 From: fcrozatier Date: Thu, 20 Nov 2025 14:17:43 +0100 Subject: [PATCH 18/46] format --- packages/typed-url-pattern/src/typedURLPattern.ts | 14 +++----------- 1 file changed, 3 insertions(+), 11 deletions(-) diff --git a/packages/typed-url-pattern/src/typedURLPattern.ts b/packages/typed-url-pattern/src/typedURLPattern.ts index ebb9b46..8ccaf2c 100644 --- a/packages/typed-url-pattern/src/typedURLPattern.ts +++ b/packages/typed-url-pattern/src/typedURLPattern.ts @@ -223,16 +223,8 @@ export class TypedURLPattern< } } -type Pretty = - & { - [K in keyof T]: T[K]; - } - & {}; +type Pretty = { [K in keyof T]: T[K] } & {}; type ConditionalOptional = - & { - [K in T as Condition extends true ? K : never]?: U; - } - & { - [K in T as Condition extends true ? never : K]: U; - }; + & { [K in T as Condition extends true ? K : never]?: U } + & { [K in T as Condition extends true ? never : K]: U }; From 1772664495d81c6205066a727c70583cd71618e0 Mon Sep 17 00:00:00 2001 From: fcrozatier Date: Thu, 20 Nov 2025 14:17:58 +0100 Subject: [PATCH 19/46] unused test --- .../typed-url-pattern/src/typedURLPattern.test.ts | 14 -------------- 1 file changed, 14 deletions(-) diff --git a/packages/typed-url-pattern/src/typedURLPattern.test.ts b/packages/typed-url-pattern/src/typedURLPattern.test.ts index aed1672..acebc24 100644 --- a/packages/typed-url-pattern/src/typedURLPattern.test.ts +++ b/packages/typed-url-pattern/src/typedURLPattern.test.ts @@ -172,20 +172,6 @@ Deno.test("href() URL-encodes parameters", () => { assertEquals(url, `${BASE_URL}/u/John%20Doe`); }); -Deno.test("href() handles optional sections gracefully", () => { - const route = new TypedURLPattern({ - pathname: "/page/:id", - search: "q=:q", - hash: ":x", - }); - - const url = route.href({ - params: { id: "12" }, - }); - - assertEquals(url, `${BASE_URL}/page/12`); -}); - Deno.test.only("throws if pathname param is missing", () => { const route = new TypedURLPattern({ pathname: "/test", From 3507d85da2e9affd8873d7bd712b604f0e416759 Mon Sep 17 00:00:00 2001 From: fcrozatier Date: Thu, 20 Nov 2025 14:31:38 +0100 Subject: [PATCH 20/46] compute baseURL if needed --- packages/typed-url-pattern/src/typedURLPattern.ts | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/packages/typed-url-pattern/src/typedURLPattern.ts b/packages/typed-url-pattern/src/typedURLPattern.ts index 8ccaf2c..5d35515 100644 --- a/packages/typed-url-pattern/src/typedURLPattern.ts +++ b/packages/typed-url-pattern/src/typedURLPattern.ts @@ -184,6 +184,18 @@ export class TypedURLPattern< }; const pattern = this.pattern; + let baseURL = this.baseURL; + + // `baseURL` can be an empty string here + if (!baseURL) { + const protocol = this.pattern.protocol; + const hostname = this.pattern.hostname; + const port = this.pattern.port ? ":" + this.pattern.port : ""; + + baseURL = protocol + "://" + hostname + port; + this.baseURL = baseURL; + } + let pathname = pattern.pathname; if (params) { @@ -219,7 +231,7 @@ export class TypedURLPattern< const _hash = typeof hash === "string" ? "#" + hash : ""; - return this.baseURL + pathname + search + _hash; + return baseURL + pathname + search + _hash; } } From 15c9e27d975023c6d592843ec04608aa3b2cd7ea Mon Sep 17 00:00:00 2001 From: fcrozatier Date: Thu, 20 Nov 2025 14:44:12 +0100 Subject: [PATCH 21/46] allow full URL match --- packages/typed-url-pattern/src/typedURLPattern.test.ts | 10 ++++++++++ packages/typed-url-pattern/src/typedURLPattern.ts | 5 ++++- 2 files changed, 14 insertions(+), 1 deletion(-) diff --git a/packages/typed-url-pattern/src/typedURLPattern.test.ts b/packages/typed-url-pattern/src/typedURLPattern.test.ts index acebc24..cfb6d52 100644 --- a/packages/typed-url-pattern/src/typedURLPattern.test.ts +++ b/packages/typed-url-pattern/src/typedURLPattern.test.ts @@ -7,6 +7,16 @@ const BASE_URL = "https://example.com"; TypedURLPattern.debug = true; TypedURLPattern.baseURL = BASE_URL; +Deno.test("matches full URL objects", () => { + const route = new TypedURLPattern({ + pathname: "/a/:b", + }); + + const match = route.match(new URL(`${BASE_URL}/a/xyz`)); + assertExists(match); + assertEquals(match.patternResult.pathname.input, "/a/xyz"); +}); + // Params Deno.test("type-safe params", () => { diff --git a/packages/typed-url-pattern/src/typedURLPattern.ts b/packages/typed-url-pattern/src/typedURLPattern.ts index 5d35515..71d94d3 100644 --- a/packages/typed-url-pattern/src/typedURLPattern.ts +++ b/packages/typed-url-pattern/src/typedURLPattern.ts @@ -53,7 +53,10 @@ export class TypedURLPattern< } match(input: URLPatternInput, baseURL?: string) { - const match = this.pattern.exec(input, baseURL ?? TypedURLPattern.baseURL); + const url = typeof input === "string" + ? new URL(input, baseURL ?? TypedURLPattern.baseURL) + : input; + const match = this.pattern.exec(url); if (!match) return null; const params = match?.pathname.groups; From fc75091833014c579ab545d34139d6d1db5118c1 Mon Sep 17 00:00:00 2001 From: fcrozatier Date: Thu, 20 Nov 2025 14:44:48 +0100 Subject: [PATCH 22/46] fix input type safety --- .../src/typedURLPattern.test.ts | 52 +++++-------------- .../typed-url-pattern/src/typedURLPattern.ts | 2 +- 2 files changed, 13 insertions(+), 41 deletions(-) diff --git a/packages/typed-url-pattern/src/typedURLPattern.test.ts b/packages/typed-url-pattern/src/typedURLPattern.test.ts index cfb6d52..da3c3a5 100644 --- a/packages/typed-url-pattern/src/typedURLPattern.test.ts +++ b/packages/typed-url-pattern/src/typedURLPattern.test.ts @@ -182,48 +182,20 @@ Deno.test("href() URL-encodes parameters", () => { assertEquals(url, `${BASE_URL}/u/John%20Doe`); }); -Deno.test.only("throws if pathname param is missing", () => { - const route = new TypedURLPattern({ - pathname: "/test", - baseURL: "http://example.com", - }); - route.href(); - - // const route = new TypedURLPattern({ - // pathname: "/test/:id", - // }); - - // assertThrows(() => route.href({})); +Deno.test("href() type-safe inputs", () => { + const route1 = new TypedURLPattern({ + pathname: "/test/:id", + }, { params: z.object({ id: z.number() }) }); + + // Complains if the options object is missing + // @ts-expect-error + route1.href(); + + // Complains if the params key is missing + // @ts-expect-error + route1.href({}); }); -// describe("edge cases", () => { - -// Deno.test("ignores unused search params", () => { -// const route = new TypedURLPattern({ -// pathname: "/x/:id", -// search: "?mode=:mode", -// }); - -// const url = route.href({ -// pathname: { id: "1" }, -// search: { mode: "full", extra: "zzz" } as any, -// }); - -// assertEquals(url, "/x/1?mode=full"); // `extra` ignored -// }); - -// Deno.test("matches full URL objects", () => { -// const route = new TypedURLPattern({ -// pathname: "/a/:b", -// }); - -// const match = route.exec(new URL("https://site.com/a/xyz")); -// assertExists(match); -// assertEquals(match.pathname.b, "xyz"); -// }); -// }); -// }); - // Deno.test("makes absolute hrefs when no host is provided", () => { // const pattern = new TypedURLPattern<{ params: { id: number } }>({ // pathname: "products/:id", diff --git a/packages/typed-url-pattern/src/typedURLPattern.ts b/packages/typed-url-pattern/src/typedURLPattern.ts index 71d94d3..eaf1394 100644 --- a/packages/typed-url-pattern/src/typedURLPattern.ts +++ b/packages/typed-url-pattern/src/typedURLPattern.ts @@ -180,7 +180,7 @@ export class TypedURLPattern< >, ] ): string { - const { params, searchParams, hash } = args[0] as { + const { params, searchParams, hash } = (args[0] ?? {}) as { params?: StandardSchemaV1.InferInput; searchParams?: StandardSchemaV1.InferInput; hash?: StandardSchemaV1.InferInput & string; From 45ed214b5b83047516b7cdf74f14d7c9afa53a81 Mon Sep 17 00:00:00 2001 From: fcrozatier Date: Thu, 20 Nov 2025 15:35:53 +0100 Subject: [PATCH 23/46] href handles wildcards --- .../src/typedURLPattern.test.ts | 32 ++++++++----------- .../typed-url-pattern/src/typedURLPattern.ts | 9 +++++- 2 files changed, 21 insertions(+), 20 deletions(-) diff --git a/packages/typed-url-pattern/src/typedURLPattern.test.ts b/packages/typed-url-pattern/src/typedURLPattern.test.ts index da3c3a5..2792edf 100644 --- a/packages/typed-url-pattern/src/typedURLPattern.test.ts +++ b/packages/typed-url-pattern/src/typedURLPattern.test.ts @@ -184,8 +184,8 @@ Deno.test("href() URL-encodes parameters", () => { Deno.test("href() type-safe inputs", () => { const route1 = new TypedURLPattern({ - pathname: "/test/:id", - }, { params: z.object({ id: z.number() }) }); + pathname: "/test/*/:id", + }, { params: z.object({ id: z.number(), "0": z.string() }) }); // Complains if the options object is missing // @ts-expect-error @@ -196,25 +196,19 @@ Deno.test("href() type-safe inputs", () => { route1.href({}); }); -// Deno.test("makes absolute hrefs when no host is provided", () => { -// const pattern = new TypedURLPattern<{ params: { id: number } }>({ -// pathname: "products/:id", -// }); +Deno.test("href() handles wildcards params", () => { + const route = new TypedURLPattern({ pathname: "*/images/*.jpg" }); + const url = route.href({ params: { "0": "user/recipes", "1": "cake" } }); -// assertEquals(pattern.href({ params: { id: 1 } }), "/products/1"); -// }); + assertEquals(url, `${BASE_URL}/user/recipes/images/cake.jpg`); +}); -// Deno.test("substitutes * for unnamed wildcards in variants", () => { -// const pattern = createHrefBuilder(); -// assertEquals( -// href("/files/*.jpg", { "*": "cat/dog" }), -// "/files/cat/dog.jpg", -// ); -// assertEquals( -// href("*/files/*.jpg", { "*": "cat/dog" }), -// "/cat/dog/files/cat/dog.jpg", -// ); -// }); +Deno.test("href() handles optional", () => { + const route = new TypedURLPattern({ pathname: "/images/*.jpg" }); + const url = route.href({ params: { "0": "user/recipes", "1": "cake" } }); + + assertEquals(url, `${BASE_URL}/user/recipes/images/cake.jpg`); +}); // Deno.test("fills in params", () => { // const pattern = createHrefBuilder(); diff --git a/packages/typed-url-pattern/src/typedURLPattern.ts b/packages/typed-url-pattern/src/typedURLPattern.ts index eaf1394..ba9c00e 100644 --- a/packages/typed-url-pattern/src/typedURLPattern.ts +++ b/packages/typed-url-pattern/src/typedURLPattern.ts @@ -209,7 +209,14 @@ export class TypedURLPattern< typeof value === "boolean", "Params must be strings, numbers or booleans", ); - pathname = pathname.replace(":" + key, encodeURIComponent(value)); + + if (Number.isNaN(Number(key))) { + // named groups: the key is not a number + pathname = pathname.replace(":" + key, encodeURIComponent(value)); + } else { + // unnamed groups + pathname = pathname.replace("*", String(value)); + } } } From 16271bf8b439af74b4cf0b14388c8a9b9fa9d720 Mon Sep 17 00:00:00 2001 From: fcrozatier Date: Thu, 20 Nov 2025 16:36:37 +0100 Subject: [PATCH 24/46] handle regex groups --- .../src/typedURLPattern.test.ts | 20 ++++++++++++------- .../typed-url-pattern/src/typedURLPattern.ts | 6 +++++- 2 files changed, 18 insertions(+), 8 deletions(-) diff --git a/packages/typed-url-pattern/src/typedURLPattern.test.ts b/packages/typed-url-pattern/src/typedURLPattern.test.ts index 2792edf..61b4abd 100644 --- a/packages/typed-url-pattern/src/typedURLPattern.test.ts +++ b/packages/typed-url-pattern/src/typedURLPattern.test.ts @@ -142,6 +142,19 @@ Deno.test("href() type-safe params", () => { assertEquals(url, `${BASE_URL}/users/55`); }); +Deno.test("href() type-safe params and regex", () => { + const route = new TypedURLPattern( + { pathname: "/users/:id(\\d+)" }, + { params: z.object({ id: z.number() }) }, + ); + + const url = route.href({ + params: { id: 55 }, + }); + + assertEquals(url, `${BASE_URL}/users/55`); +}); + Deno.test("href() type-safe search params", () => { const route = new TypedURLPattern({ pathname: "/search", @@ -203,13 +216,6 @@ Deno.test("href() handles wildcards params", () => { assertEquals(url, `${BASE_URL}/user/recipes/images/cake.jpg`); }); -Deno.test("href() handles optional", () => { - const route = new TypedURLPattern({ pathname: "/images/*.jpg" }); - const url = route.href({ params: { "0": "user/recipes", "1": "cake" } }); - - assertEquals(url, `${BASE_URL}/user/recipes/images/cake.jpg`); -}); - // Deno.test("fills in params", () => { // const pattern = createHrefBuilder(); diff --git a/packages/typed-url-pattern/src/typedURLPattern.ts b/packages/typed-url-pattern/src/typedURLPattern.ts index ba9c00e..1fc7ac9 100644 --- a/packages/typed-url-pattern/src/typedURLPattern.ts +++ b/packages/typed-url-pattern/src/typedURLPattern.ts @@ -212,7 +212,11 @@ export class TypedURLPattern< if (Number.isNaN(Number(key))) { // named groups: the key is not a number - pathname = pathname.replace(":" + key, encodeURIComponent(value)); + // also remove optional regex as in :id(\\d+) + pathname = pathname.replace( + new RegExp(":" + key + "([(][^\)]+[\)])?"), + encodeURIComponent(value), + ); } else { // unnamed groups pathname = pathname.replace("*", String(value)); From a0238245b9a45d6504dec6cf66042d24818c4737 Mon Sep 17 00:00:00 2001 From: fcrozatier Date: Thu, 20 Nov 2025 17:59:24 +0100 Subject: [PATCH 25/46] improve type safety --- .../typed-url-pattern/src/typedURLPattern.ts | 66 +++++++++++++++---- packages/typed-url-pattern/src/utils.ts | 8 +++ 2 files changed, 62 insertions(+), 12 deletions(-) diff --git a/packages/typed-url-pattern/src/typedURLPattern.ts b/packages/typed-url-pattern/src/typedURLPattern.ts index 1fc7ac9..3c687dd 100644 --- a/packages/typed-url-pattern/src/typedURLPattern.ts +++ b/packages/typed-url-pattern/src/typedURLPattern.ts @@ -1,6 +1,15 @@ import type { StandardSchemaV1 } from "@standard-schema/spec"; import { assert } from "@std/assert/assert"; -import { findBaseURL } from "./utils.ts"; +import { + findBaseURL, + NEGATIVE_LOOKAHEAD, + NEGATIVE_LOOKBEHIND, + POSITIVE_LOOKAHEAD, + POSITIVE_LOOKBEHIND, + UNNAMED_GROUP, +} from "./utils.ts"; +import { assertExists } from "@std/assert/exists"; +import { assertEquals } from "@std/assert/equals"; export class TypedURLPattern< T extends StandardSchemaV1, @@ -136,22 +145,30 @@ export class TypedURLPattern< } href( - ...args: - (unknown extends StandardSchemaV1.InferInput - ? unknown extends StandardSchemaV1.InferInput - ? unknown extends StandardSchemaV1.InferInput ? true : false - : false - : false) extends true ? [ + // The options object itself is optional if no schema is defined or all their keys are optional + ...args: ( + // No schema is defined + // deno-fmt-ignore + And< + unknown extends StandardSchemaV1.InferInput ? true : AreAllKeysOptional>, + And< + unknown extends StandardSchemaV1.InferInput ? true : AreAllKeysOptional>, + unknown extends StandardSchemaV1.InferInput ? true : AreAllKeysOptional> + > + > + ) extends true ? [ options?: Pretty< & ConditionalOptional< "params", StandardSchemaV1.InferInput, - unknown extends StandardSchemaV1.InferInput ? true : false + unknown extends StandardSchemaV1.InferInput ? true + : AreAllKeysOptional> > & ConditionalOptional< "searchParams", StandardSchemaV1.InferInput, - unknown extends StandardSchemaV1.InferInput ? true : false + unknown extends StandardSchemaV1.InferInput ? true + : AreAllKeysOptional> > & ConditionalOptional< "hash", @@ -165,17 +182,20 @@ export class TypedURLPattern< & ConditionalOptional< "params", StandardSchemaV1.InferInput, - unknown extends StandardSchemaV1.InferInput ? true : false + unknown extends StandardSchemaV1.InferInput ? true + : AreAllKeysOptional> > & ConditionalOptional< "searchParams", StandardSchemaV1.InferInput, - unknown extends StandardSchemaV1.InferInput ? true : false + unknown extends StandardSchemaV1.InferInput ? true + : AreAllKeysOptional> > & ConditionalOptional< "hash", StandardSchemaV1.InferInput & string, - unknown extends StandardSchemaV1.InferInput ? true : false + unknown extends StandardSchemaV1.InferInput ? true + : AreAllKeysOptional> > >, ] @@ -254,3 +274,25 @@ type Pretty = { [K in keyof T]: T[K] } & {}; type ConditionalOptional = & { [K in T as Condition extends true ? K : never]?: U } & { [K in T as Condition extends true ? never : K]: U }; + +type FilterRequiredKeys = { + [K in keyof T as undefined extends T[K] ? never : K]: T[K]; +}; + +type AreAllKeysOptional = {} extends FilterRequiredKeys ? true : false; + +// deno-fmt-ignore +type And = + T extends true + ? U extends true + ? true + : false + : false; + +// deno-fmt-ignore +type Or = + T extends true + ? true + : U extends true + ? true + : false; diff --git a/packages/typed-url-pattern/src/utils.ts b/packages/typed-url-pattern/src/utils.ts index 0a3bbbf..f59c112 100644 --- a/packages/typed-url-pattern/src/utils.ts +++ b/packages/typed-url-pattern/src/utils.ts @@ -5,3 +5,11 @@ export function findBaseURL(input: string) { const index = input.indexOf("/", 8); // look for the first / after https?:// return index === -1 ? input : input.slice(0, index); } + +export const POSITIVE_LOOKAHEAD = /\(\?=[^\)]+\)/; +export const NEGATIVE_LOOKAHEAD = /\(\?![^\)]+\)/; +export const POSITIVE_LOOKBEHIND = /\(\?<=[^\)]+\)/; +export const NEGATIVE_LOOKBEHIND = /\(\? Date: Fri, 21 Nov 2025 10:51:29 +0100 Subject: [PATCH 26/46] make lookaround global --- packages/typed-url-pattern/src/utils.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/typed-url-pattern/src/utils.ts b/packages/typed-url-pattern/src/utils.ts index f59c112..a396f64 100644 --- a/packages/typed-url-pattern/src/utils.ts +++ b/packages/typed-url-pattern/src/utils.ts @@ -6,10 +6,10 @@ export function findBaseURL(input: string) { return index === -1 ? input : input.slice(0, index); } -export const POSITIVE_LOOKAHEAD = /\(\?=[^\)]+\)/; -export const NEGATIVE_LOOKAHEAD = /\(\?![^\)]+\)/; -export const POSITIVE_LOOKBEHIND = /\(\?<=[^\)]+\)/; -export const NEGATIVE_LOOKBEHIND = /\(\? Date: Fri, 21 Nov 2025 11:31:34 +0100 Subject: [PATCH 27/46] more tests --- .../src/typedURLPattern.test.ts | 63 ++++++++- .../typed-url-pattern/src/typedURLPattern.ts | 126 +++++++++++++----- 2 files changed, 149 insertions(+), 40 deletions(-) diff --git a/packages/typed-url-pattern/src/typedURLPattern.test.ts b/packages/typed-url-pattern/src/typedURLPattern.test.ts index 61b4abd..885ec0f 100644 --- a/packages/typed-url-pattern/src/typedURLPattern.test.ts +++ b/packages/typed-url-pattern/src/typedURLPattern.test.ts @@ -1,6 +1,11 @@ -import { assert, assertEquals, assertExists, assertThrows } from "@std/assert"; -import { TypedURLPattern } from "./typedURLPattern.ts"; +import { + assertEquals, + assertExists, + assertInstanceOf, + unreachable, +} from "@std/assert"; import * as z from "zod"; +import { TypedURLPattern } from "@f-stack/typed-url-pattern"; const BASE_URL = "https://example.com"; @@ -142,7 +147,59 @@ Deno.test("href() type-safe params", () => { assertEquals(url, `${BASE_URL}/users/55`); }); -Deno.test("href() type-safe params and regex", () => { +Deno.test("href() pathname with unnamed matching group", () => { + const route = new TypedURLPattern({ pathname: "(/a.*)" }); + + const url = route.href({ + params: { "0": "/ab" }, + }); + + assertEquals(url, `${BASE_URL}/ab`); +}); + +Deno.test("href() pathname with lookaround assertions", () => { + const route = new TypedURLPattern( + { pathname: "(/a(?=b).*)" }, + ); + + const url = route.href({ params: { "0": "ab" } }); + + assertEquals(url, `${BASE_URL}/ab`); +}); + +Deno.test("href() params validation", () => { + const route = new TypedURLPattern( + { pathname: "(/a(?=b).*)" }, + { params: z.object({ 0: z.string().startsWith("ab") }) }, + ); + + try { + route.href({ params: { "0": "ax" } }); + unreachable(); + } catch (error) { + assertInstanceOf(error, TypeError); + assertEquals(error.message, "[TypedURLPattern]: Invalid href params"); + } +}); + +Deno.test("href() pathname with optional group", () => { + const route = new TypedURLPattern( + { pathname: "/books/:id?" }, + { params: z.object({ id: z.number().optional() }) }, + ); + + const url1 = route.href({ + params: { id: 5 }, + }); + + assertEquals(url1, `${BASE_URL}/books/5`); + + const url2 = route.href(); + + assertEquals(url2, `${BASE_URL}/books`); +}); + +Deno.test("href() pathname with wildcard", () => { const route = new TypedURLPattern( { pathname: "/users/:id(\\d+)" }, { params: z.object({ id: z.number() }) }, diff --git a/packages/typed-url-pattern/src/typedURLPattern.ts b/packages/typed-url-pattern/src/typedURLPattern.ts index 3c687dd..ed64c3f 100644 --- a/packages/typed-url-pattern/src/typedURLPattern.ts +++ b/packages/typed-url-pattern/src/typedURLPattern.ts @@ -4,6 +4,7 @@ import { findBaseURL, NEGATIVE_LOOKAHEAD, NEGATIVE_LOOKBEHIND, + OPTIONAL_NAMED_GROUP, POSITIVE_LOOKAHEAD, POSITIVE_LOOKBEHIND, UNNAMED_GROUP, @@ -157,48 +158,48 @@ export class TypedURLPattern< > > ) extends true ? [ - options?: Pretty< - & ConditionalOptional< - "params", - StandardSchemaV1.InferInput, + options?: Pretty< + & ConditionalOptional< + "params", + StandardSchemaV1.InferInput, unknown extends StandardSchemaV1.InferInput ? true : AreAllKeysOptional> - > - & ConditionalOptional< - "searchParams", - StandardSchemaV1.InferInput, + > + & ConditionalOptional< + "searchParams", + StandardSchemaV1.InferInput, unknown extends StandardSchemaV1.InferInput ? true : AreAllKeysOptional> - > - & ConditionalOptional< - "hash", - StandardSchemaV1.InferInput & string, - unknown extends StandardSchemaV1.InferInput ? true : false - > - >, - ] - : [ - options: Pretty< - & ConditionalOptional< - "params", - StandardSchemaV1.InferInput, + > + & ConditionalOptional< + "hash", + StandardSchemaV1.InferInput & string, + unknown extends StandardSchemaV1.InferInput ? true : false + > + >, + ] + : [ + options: Pretty< + & ConditionalOptional< + "params", + StandardSchemaV1.InferInput, unknown extends StandardSchemaV1.InferInput ? true : AreAllKeysOptional> - > - & ConditionalOptional< - "searchParams", - StandardSchemaV1.InferInput, + > + & ConditionalOptional< + "searchParams", + StandardSchemaV1.InferInput, unknown extends StandardSchemaV1.InferInput ? true : AreAllKeysOptional> - > - & ConditionalOptional< - "hash", - StandardSchemaV1.InferInput & string, + > + & ConditionalOptional< + "hash", + StandardSchemaV1.InferInput & string, unknown extends StandardSchemaV1.InferInput ? true : AreAllKeysOptional> - > - >, - ] + > + >, + ] ): string { const { params, searchParams, hash } = (args[0] ?? {}) as { params?: StandardSchemaV1.InferInput; @@ -211,9 +212,9 @@ export class TypedURLPattern< // `baseURL` can be an empty string here if (!baseURL) { - const protocol = this.pattern.protocol; - const hostname = this.pattern.hostname; - const port = this.pattern.port ? ":" + this.pattern.port : ""; + const protocol = pattern.protocol; + const hostname = pattern.hostname; + const port = pattern.port ? ":" + pattern.port : ""; baseURL = protocol + "://" + hostname + port; this.baseURL = baseURL; @@ -221,7 +222,33 @@ export class TypedURLPattern< let pathname = pattern.pathname; + // Remove lookaround assertions + pathname = pathname + .replaceAll(POSITIVE_LOOKAHEAD, "") + .replaceAll(NEGATIVE_LOOKAHEAD, "") + .replaceAll(POSITIVE_LOOKBEHIND, "") + .replaceAll(NEGATIVE_LOOKBEHIND, ""); + if (params) { + if (this.#paramsSchema) { + const result = this.#paramsSchema["~standard"].validate(params); + + if (result instanceof Promise) { + throw new TypeError( + "[TypedURLPattern]: URL Pattern validation must be synchronous", + ); + } + + if (result.issues) { + throw new TypeError( + "[TypedURLPattern]: Invalid href params", + ); + } + } + + // handle unnamed groups after named groups have been normalized + const unnamedGroups: [number, string][] = []; + for (const [key, value] of Object.entries(params)) { assert( typeof value === "string" || @@ -234,16 +261,41 @@ export class TypedURLPattern< // named groups: the key is not a number // also remove optional regex as in :id(\\d+) pathname = pathname.replace( - new RegExp(":" + key + "([(][^\)]+[\)])?"), + new RegExp(":" + key + "([(][^\)]+[\)])?[?]?"), encodeURIComponent(value), ); } else { // unnamed groups - pathname = pathname.replace("*", String(value)); + unnamedGroups.push([Number(key), String(value)]); } } + + // Remove unspecified optional named groups + pathname = pathname.replaceAll(OPTIONAL_NAMED_GROUP, ""); + + // Remaining groups are all unnamed and can be replaced in ascending order + + unnamedGroups.sort((a, b) => a[0] - b[0]); + + for (let i = 0; i < unnamedGroups.length; i++) { + const group = unnamedGroups[i]; + assertExists(group, `[TypedURLPattern]: Missing unnamed param ${i}`); + + const [index, value] = group; + assertEquals(index, i, `[TypedURLPattern]: Missing unnamed param ${i}`); + + pathname = pathname.replace(UNNAMED_GROUP, String(value)); + } + } else { + // Remove unspecified optional named groups + pathname = pathname.replaceAll(OPTIONAL_NAMED_GROUP, ""); } + // Edge case: collapse double slashes + // A pathname like (/a.*) when retrieved from the pattern with a base URL is /(/a.*) and, if substituted by /ab yields an unexpected double + // Example: new URLPattern({ pathname: "(/a.*)", baseURL: "https://example.com" }) + pathname = pathname.replace("//", "/"); + let search = ""; if (searchParams) { From 7b8585587b61e939552db48f3af9b25841276947 Mon Sep 17 00:00:00 2001 From: fcrozatier Date: Fri, 21 Nov 2025 11:31:44 +0100 Subject: [PATCH 28/46] optional named group --- packages/typed-url-pattern/src/utils.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/typed-url-pattern/src/utils.ts b/packages/typed-url-pattern/src/utils.ts index a396f64..8be9e19 100644 --- a/packages/typed-url-pattern/src/utils.ts +++ b/packages/typed-url-pattern/src/utils.ts @@ -13,3 +13,4 @@ export const NEGATIVE_LOOKBEHIND = /\(\? Date: Fri, 21 Nov 2025 11:35:22 +0100 Subject: [PATCH 29/46] final href validation --- packages/typed-url-pattern/src/typedURLPattern.ts | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/packages/typed-url-pattern/src/typedURLPattern.ts b/packages/typed-url-pattern/src/typedURLPattern.ts index ed64c3f..472db20 100644 --- a/packages/typed-url-pattern/src/typedURLPattern.ts +++ b/packages/typed-url-pattern/src/typedURLPattern.ts @@ -316,8 +316,13 @@ export class TypedURLPattern< } const _hash = typeof hash === "string" ? "#" + hash : ""; + const href = baseURL + pathname + search + _hash; - return baseURL + pathname + search + _hash; + if (!pattern.exec(href)) { + throw new TypeError("[TypedURLPattern]: href doesn't match the pattern"); + } + + return href; } } From 77cf1773941509f4bd957ad6016799a74497a047 Mon Sep 17 00:00:00 2001 From: fcrozatier Date: Fri, 21 Nov 2025 12:13:39 +0100 Subject: [PATCH 30/46] optionally encodeURI --- .../src/typedURLPattern.test.ts | 33 +++++++++++++++---- .../typed-url-pattern/src/typedURLPattern.ts | 14 +++++--- 2 files changed, 36 insertions(+), 11 deletions(-) diff --git a/packages/typed-url-pattern/src/typedURLPattern.test.ts b/packages/typed-url-pattern/src/typedURLPattern.test.ts index 885ec0f..d421da0 100644 --- a/packages/typed-url-pattern/src/typedURLPattern.test.ts +++ b/packages/typed-url-pattern/src/typedURLPattern.test.ts @@ -147,11 +147,11 @@ Deno.test("href() type-safe params", () => { assertEquals(url, `${BASE_URL}/users/55`); }); -Deno.test("href() pathname with unnamed matching group", () => { - const route = new TypedURLPattern({ pathname: "(/a.*)" }); +Deno.test("href() pathname with unnamed group", () => { + const route = new TypedURLPattern({ pathname: "/(a.*)" }); const url = route.href({ - params: { "0": "/ab" }, + params: { "0": "ab" }, }); assertEquals(url, `${BASE_URL}/ab`); @@ -159,7 +159,7 @@ Deno.test("href() pathname with unnamed matching group", () => { Deno.test("href() pathname with lookaround assertions", () => { const route = new TypedURLPattern( - { pathname: "(/a(?=b).*)" }, + { pathname: "/(a(?=b).*)" }, ); const url = route.href({ params: { "0": "ab" } }); @@ -199,6 +199,19 @@ Deno.test("href() pathname with optional group", () => { assertEquals(url2, `${BASE_URL}/books`); }); +Deno.test("href() pathname with repeated group", () => { + const route = new TypedURLPattern( + { pathname: "/books/:id+" }, + { params: z.object({ id: z.string() }) }, + ); + + const url1 = route.href({ params: { id: "5" } }); + assertEquals(url1, `${BASE_URL}/books/5`); + + const url2 = route.href({ params: { id: "123/456" } }); + assertEquals(url2, `${BASE_URL}/books/123/456`); +}); + Deno.test("href() pathname with wildcard", () => { const route = new TypedURLPattern( { pathname: "/users/:id(\\d+)" }, @@ -245,11 +258,19 @@ Deno.test("href() URL-encodes parameters", () => { pathname: "/u/:name", }); - const url = route.href({ + const url1 = route.href({ params: { name: "John Doe" }, + encodeURI: true, + }); + + assertEquals(url1, `${BASE_URL}/u/John%20Doe`); + + const url2 = route.href({ + params: { name: "Hélène" }, + encodeURI: false, }); - assertEquals(url, `${BASE_URL}/u/John%20Doe`); + assertEquals(url2, `${BASE_URL}/u/Hélène`); }); Deno.test("href() type-safe inputs", () => { diff --git a/packages/typed-url-pattern/src/typedURLPattern.ts b/packages/typed-url-pattern/src/typedURLPattern.ts index 472db20..724e130 100644 --- a/packages/typed-url-pattern/src/typedURLPattern.ts +++ b/packages/typed-url-pattern/src/typedURLPattern.ts @@ -176,6 +176,7 @@ export class TypedURLPattern< StandardSchemaV1.InferInput & string, unknown extends StandardSchemaV1.InferInput ? true : false > + & { encodeURI?: boolean } >, ] : [ @@ -198,6 +199,7 @@ export class TypedURLPattern< unknown extends StandardSchemaV1.InferInput ? true : AreAllKeysOptional> > + & { encodeURI?: boolean } >, ] ): string { @@ -205,6 +207,7 @@ export class TypedURLPattern< params?: StandardSchemaV1.InferInput; searchParams?: StandardSchemaV1.InferInput; hash?: StandardSchemaV1.InferInput & string; + encodeURI?: boolean; }; const pattern = this.pattern; @@ -261,8 +264,8 @@ export class TypedURLPattern< // named groups: the key is not a number // also remove optional regex as in :id(\\d+) pathname = pathname.replace( - new RegExp(":" + key + "([(][^\)]+[\)])?[?]?"), - encodeURIComponent(value), + new RegExp(":" + key + "([(][^\)]+[\)])?[?+*]?"), + String(value), ); } else { // unnamed groups @@ -307,7 +310,7 @@ export class TypedURLPattern< typeof value === "boolean", "SearchParams must be strings, numbers or booleans", ); - entries.push(`${key}=${encodeURIComponent(value)}`); + entries.push(`${key}=${value}`); } if (entries.length) { @@ -317,12 +320,13 @@ export class TypedURLPattern< const _hash = typeof hash === "string" ? "#" + hash : ""; const href = baseURL + pathname + search + _hash; + const uri = args[0]?.encodeURI ? encodeURI(href) : href; - if (!pattern.exec(href)) { + if (!pattern.exec(uri)) { throw new TypeError("[TypedURLPattern]: href doesn't match the pattern"); } - return href; + return uri; } } From 4734a9a2bc35867dd3de27459007bc04a4b6bae9 Mon Sep 17 00:00:00 2001 From: fcrozatier Date: Fri, 21 Nov 2025 12:22:58 +0100 Subject: [PATCH 31/46] handle unmatched groups --- packages/typed-url-pattern/src/typedURLPattern.test.ts | 7 +++++++ packages/typed-url-pattern/src/typedURLPattern.ts | 4 ++++ packages/typed-url-pattern/src/utils.ts | 1 + 3 files changed, 12 insertions(+) diff --git a/packages/typed-url-pattern/src/typedURLPattern.test.ts b/packages/typed-url-pattern/src/typedURLPattern.test.ts index d421da0..e90da19 100644 --- a/packages/typed-url-pattern/src/typedURLPattern.test.ts +++ b/packages/typed-url-pattern/src/typedURLPattern.test.ts @@ -199,6 +199,13 @@ Deno.test("href() pathname with optional group", () => { assertEquals(url2, `${BASE_URL}/books`); }); +Deno.test("href() pathname with optional unmatched group ", () => { + const route = new TypedURLPattern({ pathname: "/book{s}?" }); + + const url = route.href(); + assertEquals(url, `${BASE_URL}/books`); +}); + Deno.test("href() pathname with repeated group", () => { const route = new TypedURLPattern( { pathname: "/books/:id+" }, diff --git a/packages/typed-url-pattern/src/typedURLPattern.ts b/packages/typed-url-pattern/src/typedURLPattern.ts index 724e130..5c611e3 100644 --- a/packages/typed-url-pattern/src/typedURLPattern.ts +++ b/packages/typed-url-pattern/src/typedURLPattern.ts @@ -7,6 +7,7 @@ import { OPTIONAL_NAMED_GROUP, POSITIVE_LOOKAHEAD, POSITIVE_LOOKBEHIND, + UNMATCHED_GROUP_DELIMITER, UNNAMED_GROUP, } from "./utils.ts"; import { assertExists } from "@std/assert/exists"; @@ -299,6 +300,9 @@ export class TypedURLPattern< // Example: new URLPattern({ pathname: "(/a.*)", baseURL: "https://example.com" }) pathname = pathname.replace("//", "/"); + // Replace unmatched groups by their content to handle all modifiers at once (?+*) + pathname = pathname.replaceAll(UNMATCHED_GROUP_DELIMITER, "$1"); + let search = ""; if (searchParams) { diff --git a/packages/typed-url-pattern/src/utils.ts b/packages/typed-url-pattern/src/utils.ts index 8be9e19..c4c5050 100644 --- a/packages/typed-url-pattern/src/utils.ts +++ b/packages/typed-url-pattern/src/utils.ts @@ -14,3 +14,4 @@ export const NEGATIVE_LOOKBEHIND = /\(\? Date: Fri, 21 Nov 2025 12:31:17 +0100 Subject: [PATCH 32/46] doc --- packages/typed-url-pattern/src/typedURLPattern.ts | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/packages/typed-url-pattern/src/typedURLPattern.ts b/packages/typed-url-pattern/src/typedURLPattern.ts index 5c611e3..7b33043 100644 --- a/packages/typed-url-pattern/src/typedURLPattern.ts +++ b/packages/typed-url-pattern/src/typedURLPattern.ts @@ -274,11 +274,10 @@ export class TypedURLPattern< } } - // Remove unspecified optional named groups + // 1/2 Remove unspecified optional named groups pathname = pathname.replaceAll(OPTIONAL_NAMED_GROUP, ""); - // Remaining groups are all unnamed and can be replaced in ascending order - + // 2/2 All remaining groups are unnamed and can be replaced in order unnamedGroups.sort((a, b) => a[0] - b[0]); for (let i = 0; i < unnamedGroups.length; i++) { @@ -300,7 +299,7 @@ export class TypedURLPattern< // Example: new URLPattern({ pathname: "(/a.*)", baseURL: "https://example.com" }) pathname = pathname.replace("//", "/"); - // Replace unmatched groups by their content to handle all modifiers at once (?+*) + // Now that all matched groups have been handled, replace unmatched groups by their content to handle all modifiers at once (?+*) pathname = pathname.replaceAll(UNMATCHED_GROUP_DELIMITER, "$1"); let search = ""; From fb0aff2ac03525983ec8d456cee8810f1aee334f Mon Sep 17 00:00:00 2001 From: fcrozatier Date: Fri, 21 Nov 2025 14:33:58 +0100 Subject: [PATCH 33/46] unmatched groups --- .../src/typedURLPattern.test.ts | 18 +++++++++++++++++- .../typed-url-pattern/src/typedURLPattern.ts | 12 ++++++++++-- packages/typed-url-pattern/src/utils.ts | 7 +++++-- 3 files changed, 32 insertions(+), 5 deletions(-) diff --git a/packages/typed-url-pattern/src/typedURLPattern.test.ts b/packages/typed-url-pattern/src/typedURLPattern.test.ts index e90da19..b0b0eac 100644 --- a/packages/typed-url-pattern/src/typedURLPattern.test.ts +++ b/packages/typed-url-pattern/src/typedURLPattern.test.ts @@ -199,13 +199,29 @@ Deno.test("href() pathname with optional group", () => { assertEquals(url2, `${BASE_URL}/books`); }); -Deno.test("href() pathname with optional unmatched group ", () => { +Deno.test("href() pathname with optional unmatched group", () => { const route = new TypedURLPattern({ pathname: "/book{s}?" }); const url = route.href(); assertEquals(url, `${BASE_URL}/books`); }); +Deno.test( + "href() pathname with a group delimiter containing a capturing group", + () => { + const route = new TypedURLPattern( + { pathname: "/blog/:id(\\d+){-:title}?" }, + { params: z.object({ id: z.number(), title: z.string().optional() }) }, + ); + + const url1 = route.href({ params: { id: 123, title: "my-recipe" } }); + assertEquals(url1, `${BASE_URL}/blog/123-my-recipe`); + + const url2 = route.href({ params: { id: 123 } }); + assertEquals(url2, `${BASE_URL}/blog/123`); + }, +); + Deno.test("href() pathname with repeated group", () => { const route = new TypedURLPattern( { pathname: "/books/:id+" }, diff --git a/packages/typed-url-pattern/src/typedURLPattern.ts b/packages/typed-url-pattern/src/typedURLPattern.ts index 7b33043..ffbba6a 100644 --- a/packages/typed-url-pattern/src/typedURLPattern.ts +++ b/packages/typed-url-pattern/src/typedURLPattern.ts @@ -2,6 +2,7 @@ import type { StandardSchemaV1 } from "@standard-schema/spec"; import { assert } from "@std/assert/assert"; import { findBaseURL, + HAS_NO_MISSING_CAPTURED_GROUPS, NEGATIVE_LOOKAHEAD, NEGATIVE_LOOKBEHIND, OPTIONAL_NAMED_GROUP, @@ -299,8 +300,15 @@ export class TypedURLPattern< // Example: new URLPattern({ pathname: "(/a.*)", baseURL: "https://example.com" }) pathname = pathname.replace("//", "/"); - // Now that all matched groups have been handled, replace unmatched groups by their content to handle all modifiers at once (?+*) - pathname = pathname.replaceAll(UNMATCHED_GROUP_DELIMITER, "$1"); + // group delimiters + pathname = pathname.replaceAll(UNMATCHED_GROUP_DELIMITER, (_match, $1) => { + if (HAS_NO_MISSING_CAPTURED_GROUPS.exec($1)?.[0]) { + // Replace unmatched groups by their content if they have no missing capture group + return $1; + } + // otherwise remove the group, has it's either fine if the group is optional or will error during later validation if the group is required + return ""; + }); let search = ""; diff --git a/packages/typed-url-pattern/src/utils.ts b/packages/typed-url-pattern/src/utils.ts index c4c5050..41126df 100644 --- a/packages/typed-url-pattern/src/utils.ts +++ b/packages/typed-url-pattern/src/utils.ts @@ -13,5 +13,8 @@ export const NEGATIVE_LOOKBEHIND = /\(\?[^}]+)\}[?+*]?/g; +export const HAS_NO_MISSING_CAPTURED_GROUPS = /[^:*(]*(?!:[^}]+)(?!\*)(?!\()/; From 7f892321cf4c8255bdc9b430ef9fb32ac7dc0e64 Mon Sep 17 00:00:00 2001 From: fcrozatier Date: Fri, 21 Nov 2025 14:39:57 +0100 Subject: [PATCH 34/46] move types --- .../src/typedURLPattern.test.ts | 2 ++ .../typed-url-pattern/src/typedURLPattern.ts | 32 +++---------------- packages/typed-url-pattern/src/utils.ts | 30 +++++++++++++++++ 3 files changed, 36 insertions(+), 28 deletions(-) diff --git a/packages/typed-url-pattern/src/typedURLPattern.test.ts b/packages/typed-url-pattern/src/typedURLPattern.test.ts index b0b0eac..98c390b 100644 --- a/packages/typed-url-pattern/src/typedURLPattern.test.ts +++ b/packages/typed-url-pattern/src/typedURLPattern.test.ts @@ -194,6 +194,8 @@ Deno.test("href() pathname with optional group", () => { assertEquals(url1, `${BASE_URL}/books/5`); + // handles the / prefix + // https://developer.mozilla.org/en-US/docs/Web/API/URL_Pattern_API#automatic_group_prefixing_in_pathnames const url2 = route.href(); assertEquals(url2, `${BASE_URL}/books`); diff --git a/packages/typed-url-pattern/src/typedURLPattern.ts b/packages/typed-url-pattern/src/typedURLPattern.ts index ffbba6a..d4e56d9 100644 --- a/packages/typed-url-pattern/src/typedURLPattern.ts +++ b/packages/typed-url-pattern/src/typedURLPattern.ts @@ -1,6 +1,9 @@ import type { StandardSchemaV1 } from "@standard-schema/spec"; import { assert } from "@std/assert/assert"; import { + type And, + type AreAllKeysOptional, + type ConditionalOptional, findBaseURL, HAS_NO_MISSING_CAPTURED_GROUPS, NEGATIVE_LOOKAHEAD, @@ -8,6 +11,7 @@ import { OPTIONAL_NAMED_GROUP, POSITIVE_LOOKAHEAD, POSITIVE_LOOKBEHIND, + type Pretty, UNMATCHED_GROUP_DELIMITER, UNNAMED_GROUP, } from "./utils.ts"; @@ -340,31 +344,3 @@ export class TypedURLPattern< return uri; } } - -type Pretty = { [K in keyof T]: T[K] } & {}; - -type ConditionalOptional = - & { [K in T as Condition extends true ? K : never]?: U } - & { [K in T as Condition extends true ? never : K]: U }; - -type FilterRequiredKeys = { - [K in keyof T as undefined extends T[K] ? never : K]: T[K]; -}; - -type AreAllKeysOptional = {} extends FilterRequiredKeys ? true : false; - -// deno-fmt-ignore -type And = - T extends true - ? U extends true - ? true - : false - : false; - -// deno-fmt-ignore -type Or = - T extends true - ? true - : U extends true - ? true - : false; diff --git a/packages/typed-url-pattern/src/utils.ts b/packages/typed-url-pattern/src/utils.ts index 41126df..dadf413 100644 --- a/packages/typed-url-pattern/src/utils.ts +++ b/packages/typed-url-pattern/src/utils.ts @@ -1,3 +1,33 @@ +export type Pretty = { [K in keyof T]: T[K] } & {}; + +// deno-fmt-ignore +export type ConditionalOptional = + & { [K in T as Condition extends true ? K : never]?: U } + & { [K in T as Condition extends true ? never : K]: U }; + +type FilterRequiredKeys = { + [K in keyof T as undefined extends T[K] ? never : K]: T[K]; +}; + +export type AreAllKeysOptional = {} extends FilterRequiredKeys ? true + : false; + +// deno-fmt-ignore +export type And = + T extends true + ? U extends true + ? true + : false + : false; + +// deno-fmt-ignore +export type Or = + T extends true + ? true + : U extends true + ? true + : false; + /** * Extracts the BaseURL from the input URL */ From 910de7d79f33dca13be9c63113f8a8bdb6858bca Mon Sep 17 00:00:00 2001 From: fcrozatier Date: Fri, 21 Nov 2025 15:08:09 +0100 Subject: [PATCH 35/46] replace all double // --- packages/typed-url-pattern/src/typedURLPattern.ts | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/packages/typed-url-pattern/src/typedURLPattern.ts b/packages/typed-url-pattern/src/typedURLPattern.ts index d4e56d9..cdff100 100644 --- a/packages/typed-url-pattern/src/typedURLPattern.ts +++ b/packages/typed-url-pattern/src/typedURLPattern.ts @@ -300,9 +300,8 @@ export class TypedURLPattern< } // Edge case: collapse double slashes - // A pathname like (/a.*) when retrieved from the pattern with a base URL is /(/a.*) and, if substituted by /ab yields an unexpected double - // Example: new URLPattern({ pathname: "(/a.*)", baseURL: "https://example.com" }) - pathname = pathname.replace("//", "/"); + // A pathname like (/a.*) with a base URL is normalized to /(/a.*) which yields //ab if the group is substituted by /ab + pathname = pathname.replaceAll("//", "/"); // group delimiters pathname = pathname.replaceAll(UNMATCHED_GROUP_DELIMITER, (_match, $1) => { From 6041707e5817b430fd47450b67f4b9dc1e3fa652 Mon Sep 17 00:00:00 2001 From: fcrozatier Date: Fri, 21 Nov 2025 15:12:41 +0100 Subject: [PATCH 36/46] lint --- packages/typed-url-pattern/src/typedURLPattern.ts | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/packages/typed-url-pattern/src/typedURLPattern.ts b/packages/typed-url-pattern/src/typedURLPattern.ts index cdff100..d05af54 100644 --- a/packages/typed-url-pattern/src/typedURLPattern.ts +++ b/packages/typed-url-pattern/src/typedURLPattern.ts @@ -68,7 +68,12 @@ export class TypedURLPattern< this.#hashSchema = schema?.hash; } - match(input: URLPatternInput, baseURL?: string) { + match(input: URLPatternInput, baseURL?: string): null | { + patternResult: URLPatternResult; + params: StandardSchemaV1.InferOutput; + searchParams: StandardSchemaV1.InferOutput; + hash: StandardSchemaV1.InferOutput; + } { const url = typeof input === "string" ? new URL(input, baseURL ?? TypedURLPattern.baseURL) : input; @@ -145,9 +150,9 @@ export class TypedURLPattern< return { patternResult: match, - params: parsedParams as StandardSchemaV1.InferOutput, - searchParams: parsedSearchParams as StandardSchemaV1.InferOutput, - hash: parsedHash as StandardSchemaV1.InferOutput, + params: parsedParams, + searchParams: parsedSearchParams, + hash: parsedHash, }; } From 7839d8cb1f6b44bba8d1df225a4b8ea52e5a3c54 Mon Sep 17 00:00:00 2001 From: fcrozatier Date: Fri, 21 Nov 2025 15:37:31 +0100 Subject: [PATCH 37/46] add test method --- .../typed-url-pattern/src/typedURLPattern.ts | 62 ++++++++++++++++++- 1 file changed, 61 insertions(+), 1 deletion(-) diff --git a/packages/typed-url-pattern/src/typedURLPattern.ts b/packages/typed-url-pattern/src/typedURLPattern.ts index d05af54..d89eb86 100644 --- a/packages/typed-url-pattern/src/typedURLPattern.ts +++ b/packages/typed-url-pattern/src/typedURLPattern.ts @@ -18,18 +18,33 @@ import { import { assertExists } from "@std/assert/exists"; import { assertEquals } from "@std/assert/equals"; +export type { StandardSchemaV1 } from "@standard-schema/spec"; + +/** + * `TypedURLPattern` provides a way to add param parsing, schema validation and type-safety on top of a `URLPattern` object + */ export class TypedURLPattern< T extends StandardSchemaV1, U extends StandardSchemaV1, V extends StandardSchemaV1, > { + /** + * `debug` mode will log `StandardSchema` issues when params don't pass validation + */ static debug = false; + + /** + * Defines a default baseURL for all patterns + */ static baseURL = ""; #paramsSchema?: T | undefined; #searchParamsSchema?: U | undefined; #hashSchema?: V | undefined; + /** + * The base URL of this pattern + */ baseURL = ""; /** @@ -38,7 +53,21 @@ export class TypedURLPattern< */ pattern: URLPattern; - // Provide a default baseURL + /** + * Creates a `TypedURLPattern` by composing a `URLPattern` with a `StandardSchema` providing parsing, validation and type-safety for params and searchParams + * + * @example + * + * ```ts + * const pattern = new TypedURLPattern( + * { pathname: "/blog/:year/:title" }, + * { params: z.object({ year: z.coerce.number(), title: z.string() }) }, + * ); + * ``` + * + * @param input The `URLPatternInput` + * @param schema An object containing the schemas for params, searchParams and hash + */ constructor( input: URLPatternInput, schema?: { @@ -68,6 +97,24 @@ export class TypedURLPattern< this.#hashSchema = schema?.hash; } + /** + * Match the input against this pattern. + * + * @example + * + * ```ts + * const pattern = new TypedURLPattern( + * { pathname: "/blog/:year(\\d+)" }, + * { params: z.object({ year: z.coerce.number() }) }, + * ); + * + * const match = pattern.match("/blog/abc"); + * ``` + * + * @param input An absolute URL string with an optional base, relative URL string with a required base, or individual components in the form of an `URLPatternInit` object + * + * @returns An oject containing the `URLPatternResult` object along with the parsed params and searchParams + */ match(input: URLPatternInput, baseURL?: string): null | { patternResult: URLPatternResult; params: StandardSchemaV1.InferOutput; @@ -156,6 +203,19 @@ export class TypedURLPattern< }; } + /** + * Test if a URL matches this pattern. + * + * @param input The input to test + * @returns `true` if the URL matches this pattern, `false` otherwise + */ + test(input: URLPatternInput, baseURL?: string): boolean { + return this.match(input, baseURL) !== null; + } + + /** + * Creates an href URL for this pattern. + */ href( // The options object itself is optional if no schema is defined or all their keys are optional ...args: ( From d4622c67ffc9e8d15c070eb17331eecb43e791db Mon Sep 17 00:00:00 2001 From: fcrozatier Date: Fri, 21 Nov 2025 15:37:37 +0100 Subject: [PATCH 38/46] make internal --- packages/typed-url-pattern/src/utils.ts | 44 ++++++++++++++++++------- 1 file changed, 32 insertions(+), 12 deletions(-) diff --git a/packages/typed-url-pattern/src/utils.ts b/packages/typed-url-pattern/src/utils.ts index dadf413..4c7a2f9 100644 --- a/packages/typed-url-pattern/src/utils.ts +++ b/packages/typed-url-pattern/src/utils.ts @@ -1,50 +1,70 @@ +/** @internal */ export type Pretty = { [K in keyof T]: T[K] } & {}; // deno-fmt-ignore +/** @internal */ export type ConditionalOptional = - & { [K in T as Condition extends true ? K : never]?: U } - & { [K in T as Condition extends true ? never : K]: U }; +& { [K in T as Condition extends true ? K : never]?: U } +& { [K in T as Condition extends true ? never : K]: U }; type FilterRequiredKeys = { [K in keyof T as undefined extends T[K] ? never : K]: T[K]; }; +/** @internal */ export type AreAllKeysOptional = {} extends FilterRequiredKeys ? true : false; // deno-fmt-ignore +/** @internal */ export type And = - T extends true - ? U extends true - ? true - : false - : false; +T extends true +? U extends true +? true +: false +: false; // deno-fmt-ignore +/** @internal */ export type Or = - T extends true - ? true - : U extends true - ? true - : false; +T extends true +? true +: U extends true +? true +: false; /** * Extracts the BaseURL from the input URL + * + * @internal */ export function findBaseURL(input: string) { const index = input.indexOf("/", 8); // look for the first / after https?:// return index === -1 ? input : input.slice(0, index); } +/** @internal */ export const POSITIVE_LOOKAHEAD = /\(\?=[^\)]+\)/g; + +/** @internal */ export const NEGATIVE_LOOKAHEAD = /\(\?![^\)]+\)/g; + +/** @internal */ export const POSITIVE_LOOKBEHIND = /\(\?<=[^\)]+\)/g; + +/** @internal */ export const NEGATIVE_LOOKBEHIND = /\(\?[^}]+)\}[?+*]?/g; + +/** @internal */ export const HAS_NO_MISSING_CAPTURED_GROUPS = /[^:*(]*(?!:[^}]+)(?!\*)(?!\()/; From bbd81dd534eb66a0503dca676a6d507f30ff9777 Mon Sep 17 00:00:00 2001 From: fcrozatier Date: Fri, 21 Nov 2025 15:46:26 +0100 Subject: [PATCH 39/46] always coerce to number --- .../src/typedURLPattern.test.ts | 20 +++++++++++++------ 1 file changed, 14 insertions(+), 6 deletions(-) diff --git a/packages/typed-url-pattern/src/typedURLPattern.test.ts b/packages/typed-url-pattern/src/typedURLPattern.test.ts index 98c390b..675c9e9 100644 --- a/packages/typed-url-pattern/src/typedURLPattern.test.ts +++ b/packages/typed-url-pattern/src/typedURLPattern.test.ts @@ -137,7 +137,7 @@ Deno.test("type-safe hash", () => { Deno.test("href() type-safe params", () => { const route = new TypedURLPattern( { pathname: "/users/:id" }, - { params: z.object({ id: z.number() }) }, + { params: z.object({ id: z.coerce.number() }) }, ); const url = route.href({ @@ -185,7 +185,7 @@ Deno.test("href() params validation", () => { Deno.test("href() pathname with optional group", () => { const route = new TypedURLPattern( { pathname: "/books/:id?" }, - { params: z.object({ id: z.number().optional() }) }, + { params: z.object({ id: z.coerce.number().optional() }) }, ); const url1 = route.href({ @@ -213,7 +213,12 @@ Deno.test( () => { const route = new TypedURLPattern( { pathname: "/blog/:id(\\d+){-:title}?" }, - { params: z.object({ id: z.number(), title: z.string().optional() }) }, + { + params: z.object({ + id: z.coerce.number(), + title: z.string().optional(), + }), + }, ); const url1 = route.href({ params: { id: 123, title: "my-recipe" } }); @@ -240,7 +245,7 @@ Deno.test("href() pathname with repeated group", () => { Deno.test("href() pathname with wildcard", () => { const route = new TypedURLPattern( { pathname: "/users/:id(\\d+)" }, - { params: z.object({ id: z.number() }) }, + { params: z.object({ id: z.coerce.number() }) }, ); const url = route.href({ @@ -255,7 +260,10 @@ Deno.test("href() type-safe search params", () => { pathname: "/search", search: "?page=:page&sort=:sort", }, { - searchParams: z.object({ page: z.number(), sort: z.enum(["asc", "desc"]) }), + searchParams: z.object({ + page: z.coerce.number(), + sort: z.enum(["asc", "desc"]), + }), }); const url = route.href({ @@ -301,7 +309,7 @@ Deno.test("href() URL-encodes parameters", () => { Deno.test("href() type-safe inputs", () => { const route1 = new TypedURLPattern({ pathname: "/test/*/:id", - }, { params: z.object({ id: z.number(), "0": z.string() }) }); + }, { params: z.object({ id: z.coerce.number(), "0": z.string() }) }); // Complains if the options object is missing // @ts-expect-error From 3d125ea8dc99ff2be845bb1e06393a754cfdc709 Mon Sep 17 00:00:00 2001 From: fcrozatier Date: Fri, 21 Nov 2025 15:55:49 +0100 Subject: [PATCH 40/46] add examples --- .../typed-url-pattern/src/typedURLPattern.ts | 58 +++++++++++++++++-- 1 file changed, 52 insertions(+), 6 deletions(-) diff --git a/packages/typed-url-pattern/src/typedURLPattern.ts b/packages/typed-url-pattern/src/typedURLPattern.ts index d89eb86..e971d8d 100644 --- a/packages/typed-url-pattern/src/typedURLPattern.ts +++ b/packages/typed-url-pattern/src/typedURLPattern.ts @@ -59,10 +59,17 @@ export class TypedURLPattern< * @example * * ```ts + * import * as z from "zod"; + * * const pattern = new TypedURLPattern( * { pathname: "/blog/:year/:title" }, * { params: z.object({ year: z.coerce.number(), title: z.string() }) }, * ); + * + * const match = pattern.match("/blog/2025/my-post"); + * + * match?.params.year === 2025; + * match?.params.title === "my-post"; * ``` * * @param input The `URLPatternInput` @@ -103,12 +110,17 @@ export class TypedURLPattern< * @example * * ```ts + * import * as z from "zod"; + * * const pattern = new TypedURLPattern( - * { pathname: "/blog/:year(\\d+)" }, - * { params: z.object({ year: z.coerce.number() }) }, + * { pathname: "/blog/:year/:title" }, + * { params: z.object({ year: z.coerce.number(), title: z.string() }) }, * ); * - * const match = pattern.match("/blog/abc"); + * const match = pattern.match("/blog/2025/my-post"); + * + * match?.params.year === 2025; + * match?.params.title === "my-post"; * ``` * * @param input An absolute URL string with an optional base, relative URL string with a required base, or individual components in the form of an `URLPatternInit` object @@ -214,7 +226,27 @@ export class TypedURLPattern< } /** - * Creates an href URL for this pattern. + * Creates an href URL for this pattern from the provided params. + * + * @example + * + * ```ts + * import * as z from "zod"; + * + * TypedURLPattern.baseURL = "https://example.com"; + * + * const route = new TypedURLPattern( + * { pathname: "/blog/:id(\\d+){-:title}?" }, + * { params: z.object({ id: z.coerce.number(), title: z.string().optional() }) }, + * ); + * + * const href1 = route.href({ params: { id: 123, title: "recipe" } }); + * href1 === `${TypedURLPattern.baseURL}/blog/123-my-recipe`; + * + * const href2 = route.href({ params: { id: 123 } }); + * href2 === `${TypedURLPattern.baseURL}/blog/123`; + * + * ``` */ href( // The options object itself is optional if no schema is defined or all their keys are optional @@ -247,7 +279,14 @@ export class TypedURLPattern< StandardSchemaV1.InferInput & string, unknown extends StandardSchemaV1.InferInput ? true : false > - & { encodeURI?: boolean } + & { + /** + * Whether to use `encodeURI` before returning the href + * + * @default false + */ + encodeURI?: boolean; + } >, ] : [ @@ -270,7 +309,14 @@ export class TypedURLPattern< unknown extends StandardSchemaV1.InferInput ? true : AreAllKeysOptional> > - & { encodeURI?: boolean } + & { + /** + * Whether to use `encodeURI` before returning the href + * + * @default false + */ + encodeURI?: boolean; + } >, ] ): string { From ca1e764493e6b83215dc0949c97bb1118f8b99a5 Mon Sep 17 00:00:00 2001 From: fcrozatier Date: Fri, 21 Nov 2025 16:25:11 +0100 Subject: [PATCH 41/46] test more complex wildcards --- .../src/typedURLPattern.test.ts | 18 ++++++++---------- 1 file changed, 8 insertions(+), 10 deletions(-) diff --git a/packages/typed-url-pattern/src/typedURLPattern.test.ts b/packages/typed-url-pattern/src/typedURLPattern.test.ts index 675c9e9..6f65176 100644 --- a/packages/typed-url-pattern/src/typedURLPattern.test.ts +++ b/packages/typed-url-pattern/src/typedURLPattern.test.ts @@ -38,14 +38,14 @@ Deno.test("type-safe params", () => { Deno.test("type-safe unnamed params", () => { const pattern = new TypedURLPattern( - { pathname: "/images/*.png" }, - { params: z.object({ "0": z.string() }) }, + { pathname: "/images/*/*.png" }, + { params: z.object({ "0": z.string(), 1: z.string() }) }, ); - const match = pattern.match("/images/cake.png"); + const match = pattern.match("/images/path/to/cake.png"); assertExists(match); - assertEquals(match.params, { 0: "cake" }); + assertEquals(match.params, { 0: "path/to", 1: "cake" }); }); Deno.test("params validation", () => { @@ -244,15 +244,13 @@ Deno.test("href() pathname with repeated group", () => { Deno.test("href() pathname with wildcard", () => { const route = new TypedURLPattern( - { pathname: "/users/:id(\\d+)" }, - { params: z.object({ id: z.coerce.number() }) }, + { pathname: "/assets/*/*.png" }, + { params: z.object({ 0: z.string(), 1: z.enum(["cake", "banana"]) }) }, ); - const url = route.href({ - params: { id: 55 }, - }); + const url = route.href({ params: { 0: "images/recipes", 1: "cake" } }); - assertEquals(url, `${BASE_URL}/users/55`); + assertEquals(url, `${BASE_URL}/assets/images/recipes/cake.png`); }); Deno.test("href() type-safe search params", () => { From 462702232449546c1068c55cdc33c4f828f6d378 Mon Sep 17 00:00:00 2001 From: fcrozatier Date: Fri, 21 Nov 2025 18:58:55 +0100 Subject: [PATCH 42/46] fix optional wildcards --- .../src/typedURLPattern.test.ts | 138 ++++-------------- .../typed-url-pattern/src/typedURLPattern.ts | 29 ++-- packages/typed-url-pattern/src/utils.ts | 2 +- 3 files changed, 39 insertions(+), 130 deletions(-) diff --git a/packages/typed-url-pattern/src/typedURLPattern.test.ts b/packages/typed-url-pattern/src/typedURLPattern.test.ts index 6f65176..e8c72a7 100644 --- a/packages/typed-url-pattern/src/typedURLPattern.test.ts +++ b/packages/typed-url-pattern/src/typedURLPattern.test.ts @@ -244,13 +244,20 @@ Deno.test("href() pathname with repeated group", () => { Deno.test("href() pathname with wildcard", () => { const route = new TypedURLPattern( - { pathname: "/assets/*/*.png" }, - { params: z.object({ 0: z.string(), 1: z.enum(["cake", "banana"]) }) }, + { pathname: "/assets{/*}?/*.png" }, + { + params: z.object({ + 0: z.string().optional(), + 1: z.enum(["cake", "banana"]), + }), + }, ); - const url = route.href({ params: { 0: "images/recipes", 1: "cake" } }); + const url1 = route.href({ params: { 0: "images/recipes", 1: "cake" } }); + assertEquals(url1, `${BASE_URL}/assets/images/recipes/cake.png`); - assertEquals(url, `${BASE_URL}/assets/images/recipes/cake.png`); + const url2 = route.href({ params: { 1: "banana" } }); + assertEquals(url2, `${BASE_URL}/assets/banana.png`); }); Deno.test("href() type-safe search params", () => { @@ -309,114 +316,19 @@ Deno.test("href() type-safe inputs", () => { pathname: "/test/*/:id", }, { params: z.object({ id: z.coerce.number(), "0": z.string() }) }); - // Complains if the options object is missing - // @ts-expect-error - route1.href(); - - // Complains if the params key is missing - // @ts-expect-error - route1.href({}); -}); - -Deno.test("href() handles wildcards params", () => { - const route = new TypedURLPattern({ pathname: "*/images/*.jpg" }); - const url = route.href({ params: { "0": "user/recipes", "1": "cake" } }); + try { + // Complains if the options object is missing + // @ts-expect-error + route1.href(); + } catch (error) { + assertInstanceOf(error, TypeError); + } - assertEquals(url, `${BASE_URL}/user/recipes/images/cake.jpg`); + try { + // Complains if the params key is missing + // @ts-expect-error + route1.href({}); + } catch (error) { + assertInstanceOf(error, TypeError); + } }); - -// Deno.test("fills in params", () => { -// const pattern = createHrefBuilder(); - -// assertEquals(href("products/:id", { id: "1" }), "/products/1"); -// // Number is coerced to string -// assertEquals(href("products/:id", { id: 1 }), "/products/1"); - -// assertEquals( -// href("images/*path.png", { path: "images/hero" }), -// "/images/images/hero.png", -// ); -// assertEquals( -// href("images/*.png", { "*": "images/hero" }), -// "/images/images/hero.png", -// ); - -// // Include optionals by default -// assertEquals(href("products(.md)"), "/products.md"); - -// // Omit optionals with undefined/missing params -// assertEquals(href("products/:id(.:ext)", { id: "1" }), "/products/1"); -// assertEquals(href("products(/:id)", {}), "/products"); -// assertEquals(href("products(/:id)", null), "/products"); -// }); - -// Deno.test("requires a valid pattern", () => { -// const pattern = createHrefBuilder<"products(/:id)">(); -// // @ts-expect-error invalid pattern -// assertEquals(href("does-not-exist"), "/does-not-exist"); -// }); - -// Deno.test("throws when required params are missing", () => { -// const pattern = createHrefBuilder(); -// // @ts-expect-error missing required "id" param -// assert.throws(() => href("products/:id", {}), new MissingParamError("id")); -// // @ts-expect-error missing required "category" param -// assert.throws( -// () => href("*category/products", {}), -// new MissingParamError("category"), -// ); -// }); - -// Deno.test("fills in search params", () => { -// const pattern = createHrefBuilder(); - -// assertEquals( -// href("products/:id", { id: "1" }, { sort: "asc" }), -// "/products/1?sort=asc", -// ); - -// assertEquals( -// href("products/:id", { id: "1" }, { sort: "asc", limit: "10" }), -// "/products/1?sort=asc&limit=10", -// ); - -// assertEquals( -// href("products/:id", { id: "1" }, "sort=asc&limit=10"), -// "/products/1?sort=asc&limit=10", -// ); - -// assertEquals( -// href( -// "products/:id", -// { id: "1" }, -// new URLSearchParams("sort=asc&limit=10"), -// ), -// "/products/1?sort=asc&limit=10", -// ); - -// assertEquals( -// href("products/:id", { id: "1" }, [ -// ["sort", "asc"], -// ["limit", "10"], -// ]), -// "/products/1?sort=asc&limit=10", -// ); - -// // Preserves existing search params exactly as provided -// assertEquals( -// href("products/:id?sort=asc&limit=", { id: "1" }), -// "/products/1?sort=asc&limit=", -// ); - -// // Swaps out a new value for an existing param -// assertEquals( -// href("https://remix.run/search?q=remix", null, { q: "angular" }), -// "https://remix.run/search?q=angular", -// ); - -// // Completely replaces existing search params -// assertEquals( -// href("https://remix.run/search?q=remix", null, { some: "thing" }), -// "https://remix.run/search?some=thing", -// ); -// }); diff --git a/packages/typed-url-pattern/src/typedURLPattern.ts b/packages/typed-url-pattern/src/typedURLPattern.ts index e971d8d..75008e0 100644 --- a/packages/typed-url-pattern/src/typedURLPattern.ts +++ b/packages/typed-url-pattern/src/typedURLPattern.ts @@ -15,8 +15,6 @@ import { UNMATCHED_GROUP_DELIMITER, UNNAMED_GROUP, } from "./utils.ts"; -import { assertExists } from "@std/assert/exists"; -import { assertEquals } from "@std/assert/equals"; export type { StandardSchemaV1 } from "@standard-schema/spec"; @@ -367,7 +365,7 @@ export class TypedURLPattern< } // handle unnamed groups after named groups have been normalized - const unnamedGroups: [number, string][] = []; + const unnamedGroups = new Map(); for (const [key, value] of Object.entries(params)) { assert( @@ -386,28 +384,27 @@ export class TypedURLPattern< ); } else { // unnamed groups - unnamedGroups.push([Number(key), String(value)]); + unnamedGroups.set(Number(key), String(value)); } } - // 1/2 Remove unspecified optional named groups + // 1/3 Remove unspecified optional named groups pathname = pathname.replaceAll(OPTIONAL_NAMED_GROUP, ""); - // 2/2 All remaining groups are unnamed and can be replaced in order - unnamedGroups.sort((a, b) => a[0] - b[0]); + // 2/3 All remaining groups are unnamed and can be replaced in order + let i = 0; + pathname = pathname.replace(UNNAMED_GROUP, (match) => { + return unnamedGroups.get(i++) ?? match; + }); - for (let i = 0; i < unnamedGroups.length; i++) { - const group = unnamedGroups[i]; - assertExists(group, `[TypedURLPattern]: Missing unnamed param ${i}`); - - const [index, value] = group; - assertEquals(index, i, `[TypedURLPattern]: Missing unnamed param ${i}`); - - pathname = pathname.replace(UNNAMED_GROUP, String(value)); - } + // 3/3 Remove remaining optional unnamed groups + pathname = pathname.replaceAll(UNNAMED_GROUP, ""); } else { // Remove unspecified optional named groups pathname = pathname.replaceAll(OPTIONAL_NAMED_GROUP, ""); + + // Remove unspecified optional unnamed groups + pathname = pathname.replaceAll(UNNAMED_GROUP, ""); } // Edge case: collapse double slashes diff --git a/packages/typed-url-pattern/src/utils.ts b/packages/typed-url-pattern/src/utils.ts index 4c7a2f9..c87c599 100644 --- a/packages/typed-url-pattern/src/utils.ts +++ b/packages/typed-url-pattern/src/utils.ts @@ -57,7 +57,7 @@ export const NEGATIVE_LOOKBEHIND = /\(\? Date: Fri, 21 Nov 2025 19:12:37 +0100 Subject: [PATCH 43/46] readme --- packages/typed-url-pattern/README.md | 165 ++++++++++++++++++--------- 1 file changed, 113 insertions(+), 52 deletions(-) diff --git a/packages/typed-url-pattern/README.md b/packages/typed-url-pattern/README.md index 6117863..f096db9 100644 --- a/packages/typed-url-pattern/README.md +++ b/packages/typed-url-pattern/README.md @@ -1,87 +1,148 @@ +# TypedURLPattern + +A tiny TypeScript wrapper around the native [URLPattern](https://developer.mozilla.org/en-US/docs/Web/API/URLPattern) API providing: + +- **Type-safety** for your routes and API endpoints +- **Parsing and validation** with [Standard Schema](https://standardschema.dev/) +- **Standard syntax**: it's just `URLPattern` under the hood (use the Platform) +- A typed `href()` inverse (build URLs with type-safe params) + +## Install +## Common patterns + +- **Default base URL** + +Use the static `baseURL` property to provide a sensible default to all your patterns. + +This allows to avoid the "relative URL without a base" `TypeError` common with `URLPattern` ```ts import { TypedURLPattern } from '@f-stack/typed-url-pattern'; -import * as z from "zod"; -const userRoute = new TypedURLPattern( - { pathname: "/users/:id" }, - { params: z.object({ id: z.number() }) } -); +// in your config +TypedURLPattern.baseURL = "https://example.com"; + +const route = new TypedURLPattern({ pathname: "/blog" }); -userRoute.href({ params: { id: 123 } }); +route.test("https://example.com/blog") === true ``` -# TypedURLPattern +- **Typed named parameters** -A tiny TypeScript wrapper around the Web's native [URLPattern](https://developer.mozilla.org/en-US/docs/Web/API/URLPattern) API providing: +```ts +import { TypedURLPattern } from '@f-stack/typed-url-pattern'; +import * as z from "zod"; -- **Type-safe params** for your routes and API endpoints -- **Params validation** with [Standard Schema](https://standardschema.dev/) -- **Standard syntax**: it's just `URLPattern` under the hood (use the Platform) -- A typed `href()` inverse (build URLs with type-safe params) +const route = new TypedURLPattern( + { pathname: "/user/:name" }, + { params: z.object({ name: z.string() })} +); -## Install +const match = route.match("/user/bob"); -## Example patterns +match?.params.name === "bob"; +``` + +- **Typed wildcards** -- **Typed parameters** +Unnamed groups can be typed, parsed and validated in the order they appear ```ts -const match = route.exec("/users/42?tab=info#section=photos"); +import { TypedURLPattern } from '@f-stack/typed-url-pattern'; +import * as z from "zod"; -match.pathname.id; // string -match.search.tab; // string -match.hash.section; // string -``` +const route = new TypedURLPattern( + { pathname: "/assets/*/*.png" }, + { params: z.object({ 0: z.string(), 1: z.enum(["cake", "banana"]) })} +); -- **Typed inverse: build URLs from params** +const match = route.match("/assets/path/to/cake.png"); -```ts -route.href({ - pathname: { id: "42" }, - search: { tab: "info" }, - hash: { section: "photos" }, -}); -// "/users/42?tab=info#section=photos" +match?.params[0] === "path/to"; +match?.params[1] === "cake"; ``` -- **Zero overhead** — only a thin and fully typed layer over URLPattern. -- **Full pattern support** for pathname, search, and hash segments. -- **Great for routers**, service workers, runtime routing, API request matching, etc. +- **Typed optional searchParams** -## Quick Start +Use a `looseObject` to allow searchParams that are not specified in the schema. This is useful when you don't control how people link to your page _eg_ with search engine `utm` searchParams etc. -1. Create a typed pattern ```ts -const route = new TypedURLPattern({ - pathname: "/users/:id", - search: "?tab=:tab", - hash: "#section=:section", -}); +import { TypedURLPattern } from '@f-stack/typed-url-pattern'; +import * as z from "zod"; + +const route = new TypedURLPattern( + { pathname: "/watch" }, + { searchParams: z.looseObject({ id: z.string() })} +); + +const match = route.match("/watch?id=abc&utm=utm_source"); + +match?.searchParams.id === "abc"; + +// utm has not been stripped since we use a looseObject +match?.searchParams.utm === "utm_source"; ``` -2. Extract typed params +- **Parse parameters** + +Coerce strings extracted by `URLPattern` to numbers, booleans etc + ```ts -const match = route.exec("https://example.com/users/123?tab=info#section=photos"); +import { TypedURLPattern } from '@f-stack/typed-url-pattern'; +import * as z from "zod"; -if (match) { - match.pathname.id; // "123" - match.search.tab; // "info" - match.hash.section; // "photos" -} +const route = new TypedURLPattern( + { pathname: "/user/:id" }, + { params: z.object({ id: z.coerce.number() })} +); + +const match = route.match("/user/12"); + +match?.params.id === 12; ``` -3. Generate URLs (inverse of exec()) +- **Typed href() inverse** + +Build type safe href from your patterns and provided params. + +The following demo showcases: +- typed params and searchParams substitution +- typed optional wildcards +- typed optional group delimiters +- typed optional searchParams + ```ts -const url = route.href({ - pathname: { id: "123" }, - search: { tab: "info" }, - hash: { section: "photos" }, +import { TypedURLPattern } from '@f-stack/typed-url-pattern'; +import * as z from "zod"; + +const route = new TypedURLPattern( + { pathname: "/blog{/*}?/:id{-:title}?", baseURL: "https://example.com" }, + { + params: z.object({ + id: z.coerce.number(), + title: z.string().optional(), + 0: z.enum(["recipes", "trips"]).optional() + }), + searchParams: z.looseObject({ page: z.coerce.number() }) + }, +); + +// without title but with wildcard +const href1 = route.href({ + params: { id: 42, 0: "recipes" }, + hash: "intro", +}); + +href1 === "https://example.com/recipes/42#intro" + +// with title but without wildcard +const href2 = route.href({ + params: { id: 42, title: "my-cake" }, + searchParams: { page: 2 } }); -console.log(url); -// "/users/123?tab=info§ion=photos" +href2 === "https://example.com/42-mycake?page=2" ``` ## API From 65cec5be8a14daa7aa9183cc53703b438d225124 Mon Sep 17 00:00:00 2001 From: fcrozatier Date: Mon, 24 Nov 2025 09:16:21 +0100 Subject: [PATCH 44/46] format --- packages/typed-url-pattern/README.md | 47 ++++++++++++++++------------ 1 file changed, 27 insertions(+), 20 deletions(-) diff --git a/packages/typed-url-pattern/README.md b/packages/typed-url-pattern/README.md index f096db9..99e7494 100644 --- a/packages/typed-url-pattern/README.md +++ b/packages/typed-url-pattern/README.md @@ -1,6 +1,8 @@ # TypedURLPattern -A tiny TypeScript wrapper around the native [URLPattern](https://developer.mozilla.org/en-US/docs/Web/API/URLPattern) API providing: +A tiny TypeScript wrapper around the native +[URLPattern](https://developer.mozilla.org/en-US/docs/Web/API/URLPattern) API +providing: - **Type-safety** for your routes and API endpoints - **Parsing and validation** with [Standard Schema](https://standardschema.dev/) @@ -13,30 +15,32 @@ A tiny TypeScript wrapper around the native [URLPattern](https://developer.mozil - **Default base URL** -Use the static `baseURL` property to provide a sensible default to all your patterns. +Use the static `baseURL` property to provide a sensible default to all your +patterns. -This allows to avoid the "relative URL without a base" `TypeError` common with `URLPattern` +This allows to avoid the "relative URL without a base" `TypeError` common with +`URLPattern` ```ts -import { TypedURLPattern } from '@f-stack/typed-url-pattern'; +import { TypedURLPattern } from "@f-stack/typed-url-pattern"; // in your config TypedURLPattern.baseURL = "https://example.com"; const route = new TypedURLPattern({ pathname: "/blog" }); -route.test("https://example.com/blog") === true +route.test("https://example.com/blog") === true; ``` - **Typed named parameters** ```ts -import { TypedURLPattern } from '@f-stack/typed-url-pattern'; +import { TypedURLPattern } from "@f-stack/typed-url-pattern"; import * as z from "zod"; const route = new TypedURLPattern( { pathname: "/user/:name" }, - { params: z.object({ name: z.string() })} + { params: z.object({ name: z.string() }) }, ); const match = route.match("/user/bob"); @@ -49,12 +53,12 @@ match?.params.name === "bob"; Unnamed groups can be typed, parsed and validated in the order they appear ```ts -import { TypedURLPattern } from '@f-stack/typed-url-pattern'; +import { TypedURLPattern } from "@f-stack/typed-url-pattern"; import * as z from "zod"; const route = new TypedURLPattern( { pathname: "/assets/*/*.png" }, - { params: z.object({ 0: z.string(), 1: z.enum(["cake", "banana"]) })} + { params: z.object({ 0: z.string(), 1: z.enum(["cake", "banana"]) }) }, ); const match = route.match("/assets/path/to/cake.png"); @@ -65,15 +69,17 @@ match?.params[1] === "cake"; - **Typed optional searchParams** -Use a `looseObject` to allow searchParams that are not specified in the schema. This is useful when you don't control how people link to your page _eg_ with search engine `utm` searchParams etc. +Use a `looseObject` to allow searchParams that are not specified in the schema. +This is useful when you don't control how people link to your page _eg_ with +search engine `utm` searchParams etc. ```ts -import { TypedURLPattern } from '@f-stack/typed-url-pattern'; +import { TypedURLPattern } from "@f-stack/typed-url-pattern"; import * as z from "zod"; const route = new TypedURLPattern( { pathname: "/watch" }, - { searchParams: z.looseObject({ id: z.string() })} + { searchParams: z.looseObject({ id: z.string() }) }, ); const match = route.match("/watch?id=abc&utm=utm_source"); @@ -89,12 +95,12 @@ match?.searchParams.utm === "utm_source"; Coerce strings extracted by `URLPattern` to numbers, booleans etc ```ts -import { TypedURLPattern } from '@f-stack/typed-url-pattern'; +import { TypedURLPattern } from "@f-stack/typed-url-pattern"; import * as z from "zod"; const route = new TypedURLPattern( { pathname: "/user/:id" }, - { params: z.object({ id: z.coerce.number() })} + { params: z.object({ id: z.coerce.number() }) }, ); const match = route.match("/user/12"); @@ -107,13 +113,14 @@ match?.params.id === 12; Build type safe href from your patterns and provided params. The following demo showcases: + - typed params and searchParams substitution - typed optional wildcards - typed optional group delimiters - typed optional searchParams ```ts -import { TypedURLPattern } from '@f-stack/typed-url-pattern'; +import { TypedURLPattern } from "@f-stack/typed-url-pattern"; import * as z from "zod"; const route = new TypedURLPattern( @@ -122,9 +129,9 @@ const route = new TypedURLPattern( params: z.object({ id: z.coerce.number(), title: z.string().optional(), - 0: z.enum(["recipes", "trips"]).optional() + 0: z.enum(["recipes", "trips"]).optional(), }), - searchParams: z.looseObject({ page: z.coerce.number() }) + searchParams: z.looseObject({ page: z.coerce.number() }), }, ); @@ -134,15 +141,15 @@ const href1 = route.href({ hash: "intro", }); -href1 === "https://example.com/recipes/42#intro" +href1 === "https://example.com/recipes/42#intro"; // with title but without wildcard const href2 = route.href({ params: { id: 42, title: "my-cake" }, - searchParams: { page: 2 } + searchParams: { page: 2 }, }); -href2 === "https://example.com/42-mycake?page=2" +href2 === "https://example.com/42-mycake?page=2"; ``` ## API From cca7bbe4704fa447948535dcc3fa758cc01b30c1 Mon Sep 17 00:00:00 2001 From: fcrozatier Date: Mon, 24 Nov 2025 09:32:56 +0100 Subject: [PATCH 45/46] fix docs --- packages/typed-url-pattern/README.md | 59 +++++++++---------- .../typed-url-pattern/src/typedURLPattern.ts | 6 +- 2 files changed, 32 insertions(+), 33 deletions(-) diff --git a/packages/typed-url-pattern/README.md b/packages/typed-url-pattern/README.md index 99e7494..989ec3e 100644 --- a/packages/typed-url-pattern/README.md +++ b/packages/typed-url-pattern/README.md @@ -4,34 +4,15 @@ A tiny TypeScript wrapper around the native [URLPattern](https://developer.mozilla.org/en-US/docs/Web/API/URLPattern) API providing: -- **Type-safety** for your routes and API endpoints +- **Type-safety** for your routes, endpoints and links - **Parsing and validation** with [Standard Schema](https://standardschema.dev/) - **Standard syntax**: it's just `URLPattern` under the hood (use the Platform) -- A typed `href()` inverse (build URLs with type-safe params) +- A typed `href()` inverse (create type-safe links) ## Install ## Common patterns -- **Default base URL** - -Use the static `baseURL` property to provide a sensible default to all your -patterns. - -This allows to avoid the "relative URL without a base" `TypeError` common with -`URLPattern` - -```ts -import { TypedURLPattern } from "@f-stack/typed-url-pattern"; - -// in your config -TypedURLPattern.baseURL = "https://example.com"; - -const route = new TypedURLPattern({ pathname: "/blog" }); - -route.test("https://example.com/blog") === true; -``` - - **Typed named parameters** ```ts @@ -39,7 +20,7 @@ import { TypedURLPattern } from "@f-stack/typed-url-pattern"; import * as z from "zod"; const route = new TypedURLPattern( - { pathname: "/user/:name" }, + { pathname: "/user/:name", baseURL: "https://example.com" }, { params: z.object({ name: z.string() }) }, ); @@ -57,7 +38,7 @@ import { TypedURLPattern } from "@f-stack/typed-url-pattern"; import * as z from "zod"; const route = new TypedURLPattern( - { pathname: "/assets/*/*.png" }, + { pathname: "/assets/*/*.png", baseURL: "https://example.com" }, { params: z.object({ 0: z.string(), 1: z.enum(["cake", "banana"]) }) }, ); @@ -69,16 +50,15 @@ match?.params[1] === "cake"; - **Typed optional searchParams** -Use a `looseObject` to allow searchParams that are not specified in the schema. -This is useful when you don't control how people link to your page _eg_ with -search engine `utm` searchParams etc. +Use a `looseObject` to allow optional searchParams that are not specified in the schema. +This is useful when you don't control links to your page _eg_ search engines adding `utm` searchParams etc. ```ts import { TypedURLPattern } from "@f-stack/typed-url-pattern"; import * as z from "zod"; const route = new TypedURLPattern( - { pathname: "/watch" }, + { pathname: "/watch", baseURL: "https://example.com" }, { searchParams: z.looseObject({ id: z.string() }) }, ); @@ -90,7 +70,7 @@ match?.searchParams.id === "abc"; match?.searchParams.utm === "utm_source"; ``` -- **Parse parameters** +- **Parsing and validation** Coerce strings extracted by `URLPattern` to numbers, booleans etc @@ -99,7 +79,7 @@ import { TypedURLPattern } from "@f-stack/typed-url-pattern"; import * as z from "zod"; const route = new TypedURLPattern( - { pathname: "/user/:id" }, + { pathname: "/user/:id", baseURL: "https://example.com" }, { params: z.object({ id: z.coerce.number() }) }, ); @@ -108,9 +88,28 @@ const match = route.match("/user/12"); match?.params.id === 12; ``` +- **Default baseURL** + +Use the static `baseURL` property to provide a sensible default to all your +patterns. + +This allows to avoid the "relative URL without a base" `TypeError` common with +`URLPattern` + +```ts +import { TypedURLPattern } from "@f-stack/typed-url-pattern"; + +// once +TypedURLPattern.baseURL = "https://example.com"; + +const route = new TypedURLPattern({ pathname: "/blog" }); + +route.test("https://example.com/blog") === true; +``` + - **Typed href() inverse** -Build type safe href from your patterns and provided params. +Build type safe links from your patterns and provided params. The following demo showcases: diff --git a/packages/typed-url-pattern/src/typedURLPattern.ts b/packages/typed-url-pattern/src/typedURLPattern.ts index 75008e0..f319c42 100644 --- a/packages/typed-url-pattern/src/typedURLPattern.ts +++ b/packages/typed-url-pattern/src/typedURLPattern.ts @@ -60,7 +60,7 @@ export class TypedURLPattern< * import * as z from "zod"; * * const pattern = new TypedURLPattern( - * { pathname: "/blog/:year/:title" }, + * { pathname: "/blog/:year/:title", baseURL: "https://example.com" }, * { params: z.object({ year: z.coerce.number(), title: z.string() }) }, * ); * @@ -111,7 +111,7 @@ export class TypedURLPattern< * import * as z from "zod"; * * const pattern = new TypedURLPattern( - * { pathname: "/blog/:year/:title" }, + * { pathname: "/blog/:year/:title", baseURL: "https://example.com" }, * { params: z.object({ year: z.coerce.number(), title: z.string() }) }, * ); * @@ -132,7 +132,7 @@ export class TypedURLPattern< hash: StandardSchemaV1.InferOutput; } { const url = typeof input === "string" - ? new URL(input, baseURL ?? TypedURLPattern.baseURL) + ? new URL(input, baseURL ?? this.baseURL) : input; const match = this.pattern.exec(url); if (!match) return null; From 234cb6f0c641a4a683a4b9884976d0fa782af25d Mon Sep 17 00:00:00 2001 From: fcrozatier Date: Mon, 24 Nov 2025 09:35:45 +0100 Subject: [PATCH 46/46] format --- packages/typed-url-pattern/README.md | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/packages/typed-url-pattern/README.md b/packages/typed-url-pattern/README.md index 989ec3e..9169100 100644 --- a/packages/typed-url-pattern/README.md +++ b/packages/typed-url-pattern/README.md @@ -50,8 +50,9 @@ match?.params[1] === "cake"; - **Typed optional searchParams** -Use a `looseObject` to allow optional searchParams that are not specified in the schema. -This is useful when you don't control links to your page _eg_ search engines adding `utm` searchParams etc. +Use a `looseObject` to allow optional searchParams that are not specified in the +schema. This is useful when you don't control links to your page _eg_ search +engines adding `utm` searchParams etc. ```ts import { TypedURLPattern } from "@f-stack/typed-url-pattern";