diff --git a/packages/typed-url-pattern/README.md b/packages/typed-url-pattern/README.md new file mode 100644 index 0000000..9169100 --- /dev/null +++ b/packages/typed-url-pattern/README.md @@ -0,0 +1,155 @@ +# 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, 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 (create type-safe links) + +## Install + +## Common patterns + +- **Typed named parameters** + +```ts +import { TypedURLPattern } from "@f-stack/typed-url-pattern"; +import * as z from "zod"; + +const route = new TypedURLPattern( + { pathname: "/user/:name", baseURL: "https://example.com" }, + { params: z.object({ name: z.string() }) }, +); + +const match = route.match("/user/bob"); + +match?.params.name === "bob"; +``` + +- **Typed wildcards** + +Unnamed groups can be typed, parsed and validated in the order they appear + +```ts +import { TypedURLPattern } from "@f-stack/typed-url-pattern"; +import * as z from "zod"; + +const route = new TypedURLPattern( + { pathname: "/assets/*/*.png", baseURL: "https://example.com" }, + { params: z.object({ 0: z.string(), 1: z.enum(["cake", "banana"]) }) }, +); + +const match = route.match("/assets/path/to/cake.png"); + +match?.params[0] === "path/to"; +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. + +```ts +import { TypedURLPattern } from "@f-stack/typed-url-pattern"; +import * as z from "zod"; + +const route = new TypedURLPattern( + { pathname: "/watch", baseURL: "https://example.com" }, + { 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"; +``` + +- **Parsing and validation** + +Coerce strings extracted by `URLPattern` to numbers, booleans etc + +```ts +import { TypedURLPattern } from "@f-stack/typed-url-pattern"; +import * as z from "zod"; + +const route = new TypedURLPattern( + { pathname: "/user/:id", baseURL: "https://example.com" }, + { params: z.object({ id: z.coerce.number() }) }, +); + +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 links 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 * 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 }, +}); + +href2 === "https://example.com/42-mycake?page=2"; +``` + +## API diff --git a/packages/typed-url-pattern/deno.json b/packages/typed-url-pattern/deno.json new file mode 100644 index 0000000..abe84e7 --- /dev/null +++ b/packages/typed-url-pattern/deno.json @@ -0,0 +1,12 @@ +{ + "name": "@f-stack/typed-url-pattern", + "version": "0.1.0", + "license": "MIT", + "exports": { + ".": "./src/typedURLPattern.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.test.ts b/packages/typed-url-pattern/src/typedURLPattern.test.ts new file mode 100644 index 0000000..e8c72a7 --- /dev/null +++ b/packages/typed-url-pattern/src/typedURLPattern.test.ts @@ -0,0 +1,334 @@ +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"; + +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", () => { + 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("type-safe unnamed params", () => { + const pattern = new TypedURLPattern( + { pathname: "/images/*/*.png" }, + { params: z.object({ "0": z.string(), 1: z.string() }) }, + ); + + const match = pattern.match("/images/path/to/cake.png"); + + assertExists(match); + assertEquals(match.params, { 0: "path/to", 1: "cake" }); +}); + +Deno.test("params validation", () => { + const pattern = new TypedURLPattern( + { pathname: "/blog/:year(\\d+)" }, + { params: z.object({ year: z.coerce.number() }) }, + ); + + const match = pattern.match("/blog/abc"); + + assertEquals(match, null); +}); + +// Search Params + +Deno.test("type-safe 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("search params validation", () => { + 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("strict search params validation", () => { + 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("loose search params validation", () => { + 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); + 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", () => { + const route = new TypedURLPattern( + { pathname: "/users/:id" }, + { params: z.object({ id: z.coerce.number() }) }, + ); + + const url = route.href({ + params: { id: 55 }, + }); + + assertEquals(url, `${BASE_URL}/users/55`); +}); + +Deno.test("href() pathname with unnamed 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.coerce.number().optional() }) }, + ); + + const url1 = route.href({ + params: { id: 5 }, + }); + + 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`); +}); + +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.coerce.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+" }, + { 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: "/assets{/*}?/*.png" }, + { + params: z.object({ + 0: z.string().optional(), + 1: z.enum(["cake", "banana"]), + }), + }, + ); + + const url1 = route.href({ params: { 0: "images/recipes", 1: "cake" } }); + assertEquals(url1, `${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", () => { + const route = new TypedURLPattern({ + pathname: "/search", + search: "?page=:page&sort=:sort", + }, { + searchParams: z.object({ + page: z.coerce.number(), + sort: z.enum(["asc", "desc"]), + }), + }); + + const url = route.href({ + searchParams: { page: 2, sort: "asc" }, + }); + + assertEquals(url, `${BASE_URL}/search?page=2&sort=asc`); +}); + +Deno.test("href() with hash", () => { + const route = new TypedURLPattern({ + pathname: "/blog", + hash: ":section", + }, { + hash: z.enum(["intro", "outro"]), + }); + + const url = route.href({ hash: "intro" }); + + assertEquals(url, `${BASE_URL}/blog#intro`); +}); + +Deno.test("href() URL-encodes parameters", () => { + const route = new TypedURLPattern({ + pathname: "/u/:name", + }); + + 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(url2, `${BASE_URL}/u/Hélène`); +}); + +Deno.test("href() type-safe inputs", () => { + const route1 = new TypedURLPattern({ + pathname: "/test/*/:id", + }, { params: z.object({ id: z.coerce.number(), "0": z.string() }) }); + + try { + // Complains if the options object is missing + // @ts-expect-error + route1.href(); + } catch (error) { + assertInstanceOf(error, TypeError); + } + + try { + // Complains if the params key is missing + // @ts-expect-error + route1.href({}); + } catch (error) { + assertInstanceOf(error, TypeError); + } +}); diff --git a/packages/typed-url-pattern/src/typedURLPattern.ts b/packages/typed-url-pattern/src/typedURLPattern.ts new file mode 100644 index 0000000..f319c42 --- /dev/null +++ b/packages/typed-url-pattern/src/typedURLPattern.ts @@ -0,0 +1,453 @@ +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, + NEGATIVE_LOOKBEHIND, + OPTIONAL_NAMED_GROUP, + POSITIVE_LOOKAHEAD, + POSITIVE_LOOKBEHIND, + type Pretty, + UNMATCHED_GROUP_DELIMITER, + UNNAMED_GROUP, +} from "./utils.ts"; + +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 = ""; + + /** + * Pattern syntax + * https://developer.mozilla.org/en-US/docs/Web/API/URL_Pattern_API#pattern_syntax + */ + pattern: URLPattern; + + /** + * Creates a `TypedURLPattern` by composing a `URLPattern` with a `StandardSchema` providing parsing, validation and type-safety for params and searchParams + * + * @example + * + * ```ts + * import * as z from "zod"; + * + * const pattern = new TypedURLPattern( + * { pathname: "/blog/:year/:title", baseURL: "https://example.com" }, + * { 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` + * @param schema An object containing the schemas for params, searchParams and hash + */ + constructor( + input: URLPatternInput, + schema?: { + params?: T; + searchParams?: U; + hash?: V; + }, + ) { + 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; + this.#searchParamsSchema = schema?.searchParams; + this.#hashSchema = schema?.hash; + } + + /** + * Match the input against this pattern. + * + * @example + * + * ```ts + * import * as z from "zod"; + * + * const pattern = new TypedURLPattern( + * { pathname: "/blog/:year/:title", baseURL: "https://example.com" }, + * { 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 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; + searchParams: StandardSchemaV1.InferOutput; + hash: StandardSchemaV1.InferOutput; + } { + const url = typeof input === "string" + ? new URL(input, baseURL ?? this.baseURL) + : input; + const match = this.pattern.exec(url); + if (!match) return null; + + const params = match?.pathname.groups; + const paramsSchema = this.#paramsSchema; + + let parsedParams; + + if (paramsSchema) { + const result = paramsSchema["~standard"].validate(params); + + 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; + } + parsedParams = result.value; + } + + const search = match?.search.input; + const searchParamsSchema = this.#searchParamsSchema; + + let parsedSearchParams; + + if (searchParamsSchema) { + const searchParams = Object.fromEntries(new URLSearchParams(search)); + const result = searchParamsSchema["~standard"].validate(searchParams); + + 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; + } + 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, + searchParams: parsedSearchParams, + hash: parsedHash, + }; + } + + /** + * 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 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 + ...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 + : AreAllKeysOptional> + > + & ConditionalOptional< + "searchParams", + StandardSchemaV1.InferInput, + unknown extends StandardSchemaV1.InferInput ? true + : AreAllKeysOptional> + > + & ConditionalOptional< + "hash", + StandardSchemaV1.InferInput & string, + unknown extends StandardSchemaV1.InferInput ? true : false + > + & { + /** + * Whether to use `encodeURI` before returning the href + * + * @default false + */ + encodeURI?: boolean; + } + >, + ] + : [ + options: Pretty< + & ConditionalOptional< + "params", + StandardSchemaV1.InferInput, + unknown extends StandardSchemaV1.InferInput ? true + : AreAllKeysOptional> + > + & ConditionalOptional< + "searchParams", + StandardSchemaV1.InferInput, + unknown extends StandardSchemaV1.InferInput ? true + : AreAllKeysOptional> + > + & ConditionalOptional< + "hash", + StandardSchemaV1.InferInput & string, + unknown extends StandardSchemaV1.InferInput ? true + : AreAllKeysOptional> + > + & { + /** + * Whether to use `encodeURI` before returning the href + * + * @default false + */ + encodeURI?: boolean; + } + >, + ] + ): string { + const { params, searchParams, hash } = (args[0] ?? {}) as { + params?: StandardSchemaV1.InferInput; + searchParams?: StandardSchemaV1.InferInput; + hash?: StandardSchemaV1.InferInput & string; + encodeURI?: boolean; + }; + const pattern = this.pattern; + + let baseURL = this.baseURL; + + // `baseURL` can be an empty string here + if (!baseURL) { + const protocol = pattern.protocol; + const hostname = pattern.hostname; + const port = pattern.port ? ":" + pattern.port : ""; + + baseURL = protocol + "://" + hostname + port; + this.baseURL = baseURL; + } + + 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 = new Map(); + + for (const [key, value] of Object.entries(params)) { + assert( + typeof value === "string" || + typeof value === "number" || + typeof value === "boolean", + "Params must be strings, numbers or booleans", + ); + + if (Number.isNaN(Number(key))) { + // named groups: the key is not a number + // also remove optional regex as in :id(\\d+) + pathname = pathname.replace( + new RegExp(":" + key + "([(][^\)]+[\)])?[?+*]?"), + String(value), + ); + } else { + // unnamed groups + unnamedGroups.set(Number(key), String(value)); + } + } + + // 1/3 Remove unspecified optional named groups + pathname = pathname.replaceAll(OPTIONAL_NAMED_GROUP, ""); + + // 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; + }); + + // 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 + // 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) => { + 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 = ""; + + if (searchParams) { + const entries: string[] = []; + for (const [key, value] of Object.entries(searchParams)) { + assert( + typeof value === "string" || + typeof value === "number" || + typeof value === "boolean", + "SearchParams must be strings, numbers or booleans", + ); + entries.push(`${key}=${value}`); + } + + if (entries.length) { + search = `?${entries.join("&")}`; + } + } + + const _hash = typeof hash === "string" ? "#" + hash : ""; + const href = baseURL + pathname + search + _hash; + const uri = args[0]?.encodeURI ? encodeURI(href) : href; + + if (!pattern.exec(uri)) { + throw new TypeError("[TypedURLPattern]: href doesn't match the pattern"); + } + + return uri; + } +} 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..ffa13da --- /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("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..c87c599 --- /dev/null +++ b/packages/typed-url-pattern/src/utils.ts @@ -0,0 +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 }; + +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; + +// deno-fmt-ignore +/** @internal */ +export type Or = +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 = /[^:*(]*(?!:[^}]+)(?!\*)(?!\()/;