@cookbook/urlkit 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +707 -0
- package/dist/compile-path-wQfWAzOh.js +1318 -0
- package/dist/compile-path-wQfWAzOh.js.map +1 -0
- package/dist/compile-static-search-Cq3uaLe8.js +238 -0
- package/dist/compile-static-search-Cq3uaLe8.js.map +1 -0
- package/dist/contracts.d.ts +107 -0
- package/dist/create-url-contract-BYKPM9bn.js +1751 -0
- package/dist/create-url-contract-BYKPM9bn.js.map +1 -0
- package/dist/date/contracts.d.ts +5 -0
- package/dist/date/parse-custom-date.d.ts +7 -0
- package/dist/date/parse-date-time.d.ts +6 -0
- package/dist/date/parse-date.d.ts +6 -0
- package/dist/date/parse-unix-ms.d.ts +6 -0
- package/dist/date/parse-unix-seconds.d.ts +6 -0
- package/dist/date/serialize-custom-date.d.ts +7 -0
- package/dist/date/serialize-date-time.d.ts +6 -0
- package/dist/date/serialize-date.d.ts +6 -0
- package/dist/date/serialize-unix-ms.d.ts +6 -0
- package/dist/date/serialize-unix-seconds.d.ts +6 -0
- package/dist/errors/contracts.d.ts +5 -0
- package/dist/errors/url-kit-error.d.ts +8 -0
- package/dist/hash/build-hash.d.ts +4 -0
- package/dist/hash/compile-hash-descriptor.d.ts +2 -0
- package/dist/hash/compile-normalized-hash-descriptor.d.ts +2 -0
- package/dist/hash/compile-runtime-hash-descriptor.d.ts +3 -0
- package/dist/hash/compile-static-hash-descriptor.d.ts +3 -0
- package/dist/hash/contracts.d.ts +21 -0
- package/dist/hash/copy-normalized-hash-descriptor.d.ts +2 -0
- package/dist/hash/create-hash.d.ts +5 -0
- package/dist/hash/hash-fragment.d.ts +2 -0
- package/dist/hash/is-normalized-hash-descriptor.d.ts +2 -0
- package/dist/hash/normalize-hash.d.ts +3 -0
- package/dist/hash/parse-hash.d.ts +3 -0
- package/dist/hash/validate-normalized-hash-value.d.ts +5 -0
- package/dist/index.d.ts +21 -0
- package/dist/index.js +73 -0
- package/dist/index.js.map +1 -0
- package/dist/router-runtime.d.ts +12 -0
- package/dist/router-runtime.js +214 -0
- package/dist/router-runtime.js.map +1 -0
- package/dist/runtime/build-route-search.d.ts +22 -0
- package/dist/runtime/compile-cached-static-search.d.ts +3 -0
- package/dist/runtime/contracts.d.ts +7 -0
- package/dist/runtime/create-route-url-contract.d.ts +15 -0
- package/dist/runtime/parse-route-search.d.ts +12 -0
- package/dist/schema/array.d.ts +9 -0
- package/dist/schema/boolean.d.ts +4 -0
- package/dist/schema/compile-runtime-schema-value.d.ts +6 -0
- package/dist/schema/compile-runtime-schema.d.ts +2 -0
- package/dist/schema/contracts.d.ts +87 -0
- package/dist/schema/create-schema-builder.d.ts +2 -0
- package/dist/schema/create-schema-value-error.d.ts +3 -0
- package/dist/schema/date-time.d.ts +4 -0
- package/dist/schema/date.d.ts +14 -0
- package/dist/schema/enum-of.d.ts +7 -0
- package/dist/schema/get-runtime-schema-internals.d.ts +2 -0
- package/dist/schema/handle-runtime-schema-absence.d.ts +6 -0
- package/dist/schema/int.d.ts +4 -0
- package/dist/schema/is-runtime-schema-kind.d.ts +2 -0
- package/dist/schema/normalize-compiled-runtime-schema-value.d.ts +3 -0
- package/dist/schema/normalize-runtime-schema-value.d.ts +2 -0
- package/dist/schema/number.d.ts +4 -0
- package/dist/schema/object.d.ts +20 -0
- package/dist/schema/parse-compiled-runtime-schema-value.d.ts +3 -0
- package/dist/schema/parse-runtime-schema-value.d.ts +2 -0
- package/dist/schema/runtime-schema-symbol.d.ts +5 -0
- package/dist/schema/runtime-schema-value-context.d.ts +2 -0
- package/dist/schema/safe-runtime-schema-value.d.ts +4 -0
- package/dist/schema/serialize-compiled-runtime-schema-value.d.ts +3 -0
- package/dist/schema/serialize-runtime-schema-value.d.ts +2 -0
- package/dist/schema/string.d.ts +4 -0
- package/dist/search/append-object-search-entries.d.ts +4 -0
- package/dist/search/append-raw-search-value.d.ts +2 -0
- package/dist/search/append-search-entry.d.ts +3 -0
- package/dist/search/are-search-values-equal.d.ts +1 -0
- package/dist/search/assert-object-search-collisions.d.ts +2 -0
- package/dist/search/build-compiled-search.d.ts +3 -0
- package/dist/search/build-raw-search.d.ts +2 -0
- package/dist/search/build-schema-search.d.ts +3 -0
- package/dist/search/build-search.d.ts +6 -0
- package/dist/search/collect-object-search-paths.d.ts +2 -0
- package/dist/search/compile-search-schema.d.ts +2 -0
- package/dist/search/contracts.d.ts +73 -0
- package/dist/search/copy-raw-search-params.d.ts +2 -0
- package/dist/search/copy-unknown-structured-search.d.ts +2 -0
- package/dist/search/create-search-params.d.ts +1 -0
- package/dist/search/create-search.d.ts +4 -0
- package/dist/search/delete-search-field-raw-keys.d.ts +2 -0
- package/dist/search/filter-raw-search.d.ts +3 -0
- package/dist/search/find-object-search-raw-value.d.ts +2 -0
- package/dist/search/has-search-field-raw-value.d.ts +2 -0
- package/dist/search/is-runtime-search-field.d.ts +2 -0
- package/dist/search/join-search-strings.d.ts +1 -0
- package/dist/search/normalize-compiled-search.d.ts +3 -0
- package/dist/search/normalize-search-build-value.d.ts +2 -0
- package/dist/search/normalize-search-field-default.d.ts +2 -0
- package/dist/search/normalize-search-field-type.d.ts +2 -0
- package/dist/search/object-search-key.d.ts +4 -0
- package/dist/search/object-search-path-key.d.ts +2 -0
- package/dist/search/object-search-raw-key-path.d.ts +1 -0
- package/dist/search/omit-search.d.ts +1 -0
- package/dist/search/parse-array-search-value.d.ts +4 -0
- package/dist/search/parse-compiled-search.d.ts +2 -0
- package/dist/search/parse-object-search-value.d.ts +4 -0
- package/dist/search/parse-partial-compiled-search.d.ts +6 -0
- package/dist/search/parse-partial-schema-search.d.ts +6 -0
- package/dist/search/parse-raw-search.d.ts +2 -0
- package/dist/search/parse-search-field-value.d.ts +2 -0
- package/dist/search/parse-search.d.ts +5 -0
- package/dist/search/patch-search.d.ts +6 -0
- package/dist/search/pick-search.d.ts +1 -0
- package/dist/search/replace-search.d.ts +5 -0
- package/dist/search/search-array-format.d.ts +3 -0
- package/dist/search/search-entries.d.ts +4 -0
- package/dist/search/serialize-search-build-value.d.ts +2 -0
- package/dist/search/serialize-search-entries.d.ts +3 -0
- package/dist/static/compile-static-hash.d.ts +3 -0
- package/dist/static/compile-static-search.d.ts +3 -0
- package/dist/static/compile-static-url.d.ts +3 -0
- package/dist/static/contracts.d.ts +81 -0
- package/dist/static/create-static-search-schema.d.ts +3 -0
- package/dist/static/normalize-static-search-default.d.ts +2 -0
- package/dist/static/static-search-field-kind.d.ts +4 -0
- package/dist/static.d.ts +7 -0
- package/dist/static.js +55 -0
- package/dist/static.js.map +1 -0
- package/dist/url/assert-path-match-failure.d.ts +2 -0
- package/dist/url/build-compiled-url.d.ts +3 -0
- package/dist/url/build-url.d.ts +3 -0
- package/dist/url/coerce-path-param.d.ts +2 -0
- package/dist/url/compile-path.d.ts +2 -0
- package/dist/url/compile-runtime-url-descriptor.d.ts +2 -0
- package/dist/url/compile-url-descriptor.d.ts +13 -0
- package/dist/url/contracts.d.ts +91 -0
- package/dist/url/create-unsupported-url-method.d.ts +1 -0
- package/dist/url/create-url-contract.d.ts +3 -0
- package/dist/url/create-url.d.ts +2 -0
- package/dist/url/filter-compiled-url-search.d.ts +4 -0
- package/dist/url/format-parsed-url.d.ts +2 -0
- package/dist/url/match-url.d.ts +3 -0
- package/dist/url/normalize-compiled-url.d.ts +3 -0
- package/dist/url/normalize-path-build-params.d.ts +1 -0
- package/dist/url/normalize-url.d.ts +3 -0
- package/dist/url/parse-compiled-url.d.ts +3 -0
- package/dist/url/parse-path-pattern.d.ts +2 -0
- package/dist/url/parse-request.d.ts +2 -0
- package/dist/url/parse-url.d.ts +6 -0
- package/dist/url/patch-compiled-url-search.d.ts +3 -0
- package/dist/url/path-constraints.d.ts +5 -0
- package/dist/url/path-param-kind.d.ts +3 -0
- package/dist/url/path-segment.d.ts +11 -0
- package/dist/url/register-urlkit-path-constraints.d.ts +1 -0
- package/dist/url/replace-compiled-url-search.d.ts +3 -0
- package/dist/url/resolve-url-unknown-search.d.ts +3 -0
- package/dist/url/url-state-brand.d.ts +7 -0
- package/package.json +73 -0
package/README.md
ADDED
|
@@ -0,0 +1,707 @@
|
|
|
1
|
+
# @cookbook/urlkit
|
|
2
|
+
|
|
3
|
+
Framework-agnostic typed URL contracts for parsing, validating, normalizing, matching, and building URL state.
|
|
4
|
+
|
|
5
|
+
URLKit owns typed URL state: path params, search params, hash fragments, request parsing, URL normalization, matching, and href building. It sits between `@cookbook/pathkit` and higher-level router packages, but it does not define routes, route IDs, route trees, loaders, middleware, React hooks, components, or framework adapters.
|
|
6
|
+
|
|
7
|
+
## Status
|
|
8
|
+
|
|
9
|
+
`@cookbook/urlkit` is currently version `0.0.0`. The implementation is covered by type, unit, integration, documentation-example, and build checks, but the package should be treated as pre-1.0 until its public API is released.
|
|
10
|
+
|
|
11
|
+
## Documentation
|
|
12
|
+
|
|
13
|
+
- [Full API reference](./docs/api.md)
|
|
14
|
+
- [Focused examples](./docs/examples.md)
|
|
15
|
+
- [Release readiness notes](./release-readiness.md)
|
|
16
|
+
|
|
17
|
+
## Real-world framework examples
|
|
18
|
+
|
|
19
|
+
Full integration examples are available under [`examples/integrations`](./examples/integrations). They show the same product catalog contracts used with Next.js, Express, Hono, Fastify, React Router, Remix, and TanStack Router, including local Express/Hono/Fastify middleware wrappers that accept a URLKit contract plus options.
|
|
20
|
+
|
|
21
|
+
## Table of contents
|
|
22
|
+
|
|
23
|
+
- [Documentation](#documentation)
|
|
24
|
+
- [Real-world framework examples](#real-world-framework-examples)
|
|
25
|
+
- [Installation](#installation)
|
|
26
|
+
- [Quick start](#quick-start)
|
|
27
|
+
- [Why URLKit?](#why-urlkit)
|
|
28
|
+
- [Package exports](#package-exports)
|
|
29
|
+
- [Core concepts](#core-concepts)
|
|
30
|
+
- [`parse`, `normalize`, `build`, and `match`](#parse-normalize-build-and-match)
|
|
31
|
+
- [`UrlState`](#urlstate)
|
|
32
|
+
- [Path-based vs pathless contracts](#path-based-vs-pathless-contracts)
|
|
33
|
+
- [Path-based URL contracts](#path-based-url-contracts)
|
|
34
|
+
- [Custom path constraints](#custom-path-constraints)
|
|
35
|
+
- [Pathless URL contracts](#pathless-url-contracts)
|
|
36
|
+
- [Search-only helper](#search-only-helper)
|
|
37
|
+
- [Hash-only helper](#hash-only-helper)
|
|
38
|
+
- [Search params](#search-params)
|
|
39
|
+
- [Unknown search params](#unknown-search-params)
|
|
40
|
+
- [Defaults behavior](#defaults-behavior)
|
|
41
|
+
- [Dates](#dates)
|
|
42
|
+
- [Object search](#object-search)
|
|
43
|
+
- [Hash](#hash)
|
|
44
|
+
- [Safe APIs](#safe-apis)
|
|
45
|
+
- [Request parsing](#request-parsing)
|
|
46
|
+
- [Static descriptors](#static-descriptors)
|
|
47
|
+
- [Router-runtime usage](#router-runtime-usage)
|
|
48
|
+
- [Error handling](#error-handling)
|
|
49
|
+
- [TypeScript inference](#typescript-inference)
|
|
50
|
+
- [Framework boundary](#framework-boundary)
|
|
51
|
+
- [Testing and development](#testing-and-development)
|
|
52
|
+
|
|
53
|
+
## Installation
|
|
54
|
+
|
|
55
|
+
```sh
|
|
56
|
+
pnpm add @cookbook/urlkit
|
|
57
|
+
npm install @cookbook/urlkit
|
|
58
|
+
yarn add @cookbook/urlkit
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
## Quick start
|
|
62
|
+
|
|
63
|
+
```ts
|
|
64
|
+
import { int, string, url } from '@cookbook/urlkit';
|
|
65
|
+
|
|
66
|
+
const UserUrl = url({
|
|
67
|
+
path: '/users/{id:int}',
|
|
68
|
+
search: {
|
|
69
|
+
tab: string().default('profile'),
|
|
70
|
+
page: int().default(1),
|
|
71
|
+
},
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
const state = UserUrl.parse('/users/42?tab=settings&page=2');
|
|
75
|
+
// state.params.id: number
|
|
76
|
+
// state.search.tab: string
|
|
77
|
+
|
|
78
|
+
const href = UserUrl.build({
|
|
79
|
+
params: { id: 42 },
|
|
80
|
+
search: { tab: 'settings', page: 2 },
|
|
81
|
+
});
|
|
82
|
+
// '/users/42?tab=settings&page=2'
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
## Why URLKit?
|
|
86
|
+
|
|
87
|
+
URLs usually cross boundaries as strings, but application code wants typed state. URLKit gives you one reusable contract for:
|
|
88
|
+
|
|
89
|
+
- parsing serialized URLs into typed state
|
|
90
|
+
- normalizing structured params/search/hash from framework or server inputs
|
|
91
|
+
- building canonical URLs from typed state
|
|
92
|
+
- validating and matching URLs without routing dependencies
|
|
93
|
+
- sharing the same URL contract across browser, server, edge, router, CLI, and test environments
|
|
94
|
+
|
|
95
|
+
## Package exports
|
|
96
|
+
|
|
97
|
+
| Import path | Purpose |
|
|
98
|
+
| --------------------------------- | ---------------------------------------------------------------------------- |
|
|
99
|
+
| `@cookbook/urlkit` | Runtime URL contracts, schema builders, public contracts, and `UrlKitError`. |
|
|
100
|
+
| `@cookbook/urlkit/static` | Static descriptor compilers for router-compatible analyzable descriptors. |
|
|
101
|
+
| `@cookbook/urlkit/router-runtime` | Framework-agnostic runtime helpers for router packages. |
|
|
102
|
+
|
|
103
|
+
```ts
|
|
104
|
+
import { url, search, hash, string, int, enumOf } from '@cookbook/urlkit';
|
|
105
|
+
import { compileStaticUrl } from '@cookbook/urlkit/static';
|
|
106
|
+
import { createRouteUrlContract } from '@cookbook/urlkit/router-runtime';
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
## Core concepts
|
|
110
|
+
|
|
111
|
+
### `parse`, `normalize`, `build`, and `match`
|
|
112
|
+
|
|
113
|
+
| Method | Input | Purpose |
|
|
114
|
+
| ----------- | --------------------------------------- | ------------------------------------------------------------------------- |
|
|
115
|
+
| `parse` | Serialized URL input: `string` or `URL` | Parse and validate a URL string/object into typed `UrlState`. |
|
|
116
|
+
| `normalize` | Structured URL state | Validate/coerce params, search, and hash from application/framework data. |
|
|
117
|
+
| `build` | Typed URL state | Serialize state to a canonical URL string. |
|
|
118
|
+
| `match` | Serialized URL input: `string` or `URL` | Return `true`/`false` for ordinary URL validation. |
|
|
119
|
+
|
|
120
|
+
`parse` intentionally does **not** accept structured objects. Use `normalize` for structured state.
|
|
121
|
+
|
|
122
|
+
### `UrlState`
|
|
123
|
+
|
|
124
|
+
Parsed and normalized state always includes `pathname`, `params`, `search`, and `hash`. Optional hashes are represented as `undefined`; the `hash` property itself is still present.
|
|
125
|
+
|
|
126
|
+
```ts
|
|
127
|
+
interface UrlState<Pathname, Params, Search, Hash> {
|
|
128
|
+
readonly pathname: Pathname;
|
|
129
|
+
readonly params: Params;
|
|
130
|
+
readonly search: Search;
|
|
131
|
+
readonly hash: Hash;
|
|
132
|
+
readonly unknownSearch?: UnknownSearchParams;
|
|
133
|
+
}
|
|
134
|
+
```
|
|
135
|
+
|
|
136
|
+
Preserved unknown search params live in `state.unknownSearch`, not in `state.search`.
|
|
137
|
+
|
|
138
|
+
### Path-based vs pathless contracts
|
|
139
|
+
|
|
140
|
+
| Mode | How to create it | Path behavior | Build behavior |
|
|
141
|
+
| ---------- | ---------------- | ------------------------------------------- | ------------------------------------------------------------------- |
|
|
142
|
+
| Path-based | Provide `path` | Validates pathnames and infers path params. | Builds from `params`. |
|
|
143
|
+
| Pathless | Omit `path` | Accepts any pathname. | Without `pathname`, returns a suffix like `?page=2` or `#comments`. |
|
|
144
|
+
|
|
145
|
+
## Path-based URL contracts
|
|
146
|
+
|
|
147
|
+
Path-based contracts use `@cookbook/pathkit` for path pattern matching/building. URLKit adds typed URL state around those paths.
|
|
148
|
+
|
|
149
|
+
```ts
|
|
150
|
+
import { url } from '@cookbook/urlkit';
|
|
151
|
+
|
|
152
|
+
const ArticleUrl = url({
|
|
153
|
+
path: '/articles/{slug:regex([a-z0-9-]+)}',
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
const state = ArticleUrl.parse('/articles/post-1');
|
|
157
|
+
// state.pathname: `/articles/${string}`
|
|
158
|
+
// state.params.slug: string
|
|
159
|
+
|
|
160
|
+
ArticleUrl.build({ params: { slug: 'post-1' } });
|
|
161
|
+
// '/articles/post-1'
|
|
162
|
+
|
|
163
|
+
ArticleUrl.match('/articles/post-1');
|
|
164
|
+
// true
|
|
165
|
+
|
|
166
|
+
ArticleUrl.match('/users/post-1');
|
|
167
|
+
// false
|
|
168
|
+
```
|
|
169
|
+
|
|
170
|
+
Path-based build input uses `params`, not `pathname`:
|
|
171
|
+
|
|
172
|
+
```ts
|
|
173
|
+
ArticleUrl.build({ params: { slug: 'post-1' } });
|
|
174
|
+
|
|
175
|
+
// Invalid for path-based contracts:
|
|
176
|
+
// ArticleUrl.build({ pathname: '/articles/post-1' });
|
|
177
|
+
```
|
|
178
|
+
|
|
179
|
+
Path params are inferred from the pattern. Built-in `int` and `number` path constraints parse to numbers in standalone `url(...)` contracts.
|
|
180
|
+
|
|
181
|
+
```ts
|
|
182
|
+
const UserUrl = url({ path: '/users/{id:int}' });
|
|
183
|
+
|
|
184
|
+
const user = UserUrl.parse('/users/42');
|
|
185
|
+
// user.params.id: number
|
|
186
|
+
```
|
|
187
|
+
|
|
188
|
+
### Custom path constraints
|
|
189
|
+
|
|
190
|
+
URLKit re-exports PathKit's `createConstraint` and provides global registration helpers for reusable path constraints. Custom constraints infer `string` params by default; built-in `int` and `number` still infer `number`.
|
|
191
|
+
|
|
192
|
+
```ts
|
|
193
|
+
import { createConstraint, registerPathConstraint, url } from '@cookbook/urlkit';
|
|
194
|
+
|
|
195
|
+
const slug = createConstraint({
|
|
196
|
+
parse(paramName, value) {
|
|
197
|
+
if (!/^[a-z0-9-]+$/.test(String(value))) {
|
|
198
|
+
throw new Error(`Path parameter "${paramName}" must be a slug.`);
|
|
199
|
+
}
|
|
200
|
+
},
|
|
201
|
+
verify(paramName, params) {
|
|
202
|
+
if (params.trim()) {
|
|
203
|
+
throw new Error(`Constraint "slug" declared for "${paramName}" does not accept arguments.`);
|
|
204
|
+
}
|
|
205
|
+
},
|
|
206
|
+
toRegExp() {
|
|
207
|
+
return '[a-z0-9-]+';
|
|
208
|
+
},
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
registerPathConstraint('slug', slug);
|
|
212
|
+
|
|
213
|
+
const ArticleUrl = url({
|
|
214
|
+
path: '/articles/{slug:slug}',
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
ArticleUrl.parse('/articles/hello-world').params.slug;
|
|
218
|
+
// string
|
|
219
|
+
```
|
|
220
|
+
|
|
221
|
+
Use per-contract registration when a constraint should be local to a contract or test:
|
|
222
|
+
|
|
223
|
+
```ts
|
|
224
|
+
const ArticleUrl = url({ path: '/articles/{slug:slug}' }, { pathConstraints: { slug } });
|
|
225
|
+
```
|
|
226
|
+
|
|
227
|
+
## Pathless URL contracts
|
|
228
|
+
|
|
229
|
+
Pathless contracts validate search/hash independently of pathname. `pattern` is `undefined`, `params` is `{}`, and `parse` preserves the input pathname.
|
|
230
|
+
|
|
231
|
+
```ts
|
|
232
|
+
import { int, url } from '@cookbook/urlkit';
|
|
233
|
+
|
|
234
|
+
const FiltersUrl = url({
|
|
235
|
+
search: {
|
|
236
|
+
page: int().default(1),
|
|
237
|
+
},
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
FiltersUrl.build({
|
|
241
|
+
search: { page: 2 },
|
|
242
|
+
});
|
|
243
|
+
// '?page=2'
|
|
244
|
+
|
|
245
|
+
FiltersUrl.build({
|
|
246
|
+
pathname: '/products',
|
|
247
|
+
search: { page: 2 },
|
|
248
|
+
});
|
|
249
|
+
// '/products?page=2'
|
|
250
|
+
|
|
251
|
+
FiltersUrl.parse('/anything?page=3').pathname;
|
|
252
|
+
// '/anything'
|
|
253
|
+
```
|
|
254
|
+
|
|
255
|
+
### Search-only helper
|
|
256
|
+
|
|
257
|
+
```ts
|
|
258
|
+
import { int, search, string } from '@cookbook/urlkit';
|
|
259
|
+
|
|
260
|
+
const ProductSearch = search({
|
|
261
|
+
category: string().optional(),
|
|
262
|
+
page: int().default(1),
|
|
263
|
+
});
|
|
264
|
+
|
|
265
|
+
ProductSearch.build({ search: { page: 2 } });
|
|
266
|
+
// '?page=2'
|
|
267
|
+
```
|
|
268
|
+
|
|
269
|
+
### Hash-only helper
|
|
270
|
+
|
|
271
|
+
```ts
|
|
272
|
+
import { enumOf, hash } from '@cookbook/urlkit';
|
|
273
|
+
|
|
274
|
+
const DocsHash = hash(enumOf(['intro', 'api']).optional());
|
|
275
|
+
|
|
276
|
+
DocsHash.parse('/docs#api').hash;
|
|
277
|
+
// 'api'
|
|
278
|
+
|
|
279
|
+
DocsHash.build({ hash: 'api' });
|
|
280
|
+
// '#api'
|
|
281
|
+
```
|
|
282
|
+
|
|
283
|
+
## Search params
|
|
284
|
+
|
|
285
|
+
Runtime search schemas use builders from the main entry.
|
|
286
|
+
|
|
287
|
+
```ts
|
|
288
|
+
import { array, boolean, enumOf, int, number, string, url } from '@cookbook/urlkit';
|
|
289
|
+
|
|
290
|
+
const SearchUrl = url({
|
|
291
|
+
path: '/search',
|
|
292
|
+
search: {
|
|
293
|
+
q: string(),
|
|
294
|
+
page: int().default(1),
|
|
295
|
+
score: number().optional(),
|
|
296
|
+
active: boolean().optional(),
|
|
297
|
+
tags: array(string()).optional(),
|
|
298
|
+
sort: enumOf(['newest', 'popular']).default('newest'),
|
|
299
|
+
},
|
|
300
|
+
});
|
|
301
|
+
|
|
302
|
+
SearchUrl.parse('/search?q=url&page=2&active=true&tags=ts&tags=router');
|
|
303
|
+
|
|
304
|
+
SearchUrl.build({
|
|
305
|
+
search: {
|
|
306
|
+
q: 'url',
|
|
307
|
+
page: 2,
|
|
308
|
+
active: true,
|
|
309
|
+
tags: ['ts', 'router'],
|
|
310
|
+
sort: 'newest',
|
|
311
|
+
},
|
|
312
|
+
});
|
|
313
|
+
// '/search?q=url&page=2&active=true&tags=ts&tags=router&sort=newest'
|
|
314
|
+
```
|
|
315
|
+
|
|
316
|
+
Arrays parse and serialize as repeated params by default. Pass `{ arrayFormat: 'comma' }` to `url(...)`, `parse`, `safeParse`, `parseRequest`, `safeParseRequest`, `match`, `build`, `parseSearch`, or `buildSearch` to use comma-separated arrays. Per-call options override the contract-level default, so `{ arrayFormat: 'repeat' }` can force repeated keys on a comma-configured contract.
|
|
317
|
+
|
|
318
|
+
```ts
|
|
319
|
+
const TagUrl = url(
|
|
320
|
+
{
|
|
321
|
+
path: '/search',
|
|
322
|
+
search: {
|
|
323
|
+
tags: array(string()).optional(),
|
|
324
|
+
},
|
|
325
|
+
},
|
|
326
|
+
{ arrayFormat: 'comma' },
|
|
327
|
+
);
|
|
328
|
+
|
|
329
|
+
TagUrl.parse('/search?tags=ts%2Crouter').search.tags;
|
|
330
|
+
// ['ts', 'router']
|
|
331
|
+
|
|
332
|
+
TagUrl.build({ search: { tags: ['ts', 'router'] } });
|
|
333
|
+
// '/search?tags=ts%2Crouter'
|
|
334
|
+
|
|
335
|
+
TagUrl.build({ search: { tags: ['ts', 'router'] } }, { arrayFormat: 'repeat' });
|
|
336
|
+
// '/search?tags=ts&tags=router'
|
|
337
|
+
```
|
|
338
|
+
|
|
339
|
+
## Unknown search params
|
|
340
|
+
|
|
341
|
+
Unknown search params default to `strip`.
|
|
342
|
+
|
|
343
|
+
| Behavior | Result |
|
|
344
|
+
| ---------- | ----------------------------------------------- |
|
|
345
|
+
| `strip` | Remove unknown params from typed state. |
|
|
346
|
+
| `preserve` | Put unknown params in `state.unknownSearch`. |
|
|
347
|
+
| `error` | Throw `UrlKitError` with code `invalid-search`. |
|
|
348
|
+
|
|
349
|
+
```ts
|
|
350
|
+
const QueryUrl = url({
|
|
351
|
+
search: {
|
|
352
|
+
q: string(),
|
|
353
|
+
},
|
|
354
|
+
});
|
|
355
|
+
|
|
356
|
+
QueryUrl.parse('/search?q=router&debug=true');
|
|
357
|
+
// search: { q: 'router' }
|
|
358
|
+
|
|
359
|
+
QueryUrl.parse('/search?q=router&debug=true', { unknownSearch: 'preserve' });
|
|
360
|
+
// search: { q: 'router' }
|
|
361
|
+
// unknownSearch: { debug: 'true' }
|
|
362
|
+
|
|
363
|
+
QueryUrl.safeParse('/search?q=router&debug=true', { unknownSearch: 'error' });
|
|
364
|
+
// { success: false, error: UrlKitError }
|
|
365
|
+
```
|
|
366
|
+
|
|
367
|
+
## Defaults behavior
|
|
368
|
+
|
|
369
|
+
`parse` and `normalize` always apply defaults. `build` serializes the values it receives and includes defaults by default.
|
|
370
|
+
|
|
371
|
+
```ts
|
|
372
|
+
const Paging = search({
|
|
373
|
+
page: int().default(1),
|
|
374
|
+
});
|
|
375
|
+
|
|
376
|
+
Paging.parse('/products').search;
|
|
377
|
+
// { page: 1 }
|
|
378
|
+
|
|
379
|
+
Paging.build({ search: { page: 1 } });
|
|
380
|
+
// '?page=1'
|
|
381
|
+
|
|
382
|
+
Paging.build({ search: { page: 1 } }, { defaults: 'omit' });
|
|
383
|
+
// ''
|
|
384
|
+
```
|
|
385
|
+
|
|
386
|
+
Default omission compares normalized values, so defaults are compared after the same validation/coercion rules used by the contract.
|
|
387
|
+
|
|
388
|
+
## Dates
|
|
389
|
+
|
|
390
|
+
```ts
|
|
391
|
+
import { date, dateTime, search } from '@cookbook/urlkit';
|
|
392
|
+
|
|
393
|
+
const Reports = search({
|
|
394
|
+
day: date(),
|
|
395
|
+
at: dateTime().optional(),
|
|
396
|
+
createdAt: date({ format: 'unix-seconds' }).optional(),
|
|
397
|
+
updatedAt: date({ format: 'unix-ms' }).optional(),
|
|
398
|
+
});
|
|
399
|
+
```
|
|
400
|
+
|
|
401
|
+
| Builder | Serialized format |
|
|
402
|
+
| ---------------------------------------- | -------------------------------------- |
|
|
403
|
+
| `date()` | Date-only `YYYY-MM-DD`. |
|
|
404
|
+
| `dateTime()` | Strict UTC `YYYY-MM-DDTHH:mm:ss.sssZ`. |
|
|
405
|
+
| `date({ format: 'unix-seconds' })` | Finite integer seconds. |
|
|
406
|
+
| `date({ format: 'unix-ms' })` | Finite integer milliseconds. |
|
|
407
|
+
| `date({ format: { parse, serialize } })` | Custom runtime date codec. |
|
|
408
|
+
|
|
409
|
+
Custom runtime date codecs are available only in runtime-builder schemas. Static date defaults use serialized values, not `Date` instances.
|
|
410
|
+
|
|
411
|
+
```ts
|
|
412
|
+
const CustomDate = search({
|
|
413
|
+
from: date({
|
|
414
|
+
format: {
|
|
415
|
+
parse(value) {
|
|
416
|
+
const [day, month, year] = value.split('-');
|
|
417
|
+
return new Date(Date.UTC(Number(year), Number(month) - 1, Number(day)));
|
|
418
|
+
},
|
|
419
|
+
serialize(value) {
|
|
420
|
+
const day = String(value.getUTCDate()).padStart(2, '0');
|
|
421
|
+
const month = String(value.getUTCMonth() + 1).padStart(2, '0');
|
|
422
|
+
return `${day}-${month}-${value.getUTCFullYear()}`;
|
|
423
|
+
},
|
|
424
|
+
},
|
|
425
|
+
}),
|
|
426
|
+
});
|
|
427
|
+
|
|
428
|
+
CustomDate.build({ search: { from: new Date('2026-06-02T00:00:00.000Z') } });
|
|
429
|
+
// '?from=02-06-2026'
|
|
430
|
+
```
|
|
431
|
+
|
|
432
|
+
## Object search
|
|
433
|
+
|
|
434
|
+
`object(...)` hydrates declared object fields from dotted search keys. Raw search parsing without a schema remains flat.
|
|
435
|
+
|
|
436
|
+
```ts
|
|
437
|
+
import { boolean, object, string, search } from '@cookbook/urlkit';
|
|
438
|
+
|
|
439
|
+
const Filters = search({
|
|
440
|
+
filter: object({
|
|
441
|
+
role: string().optional(),
|
|
442
|
+
active: boolean().optional(),
|
|
443
|
+
'user.name': string().optional(),
|
|
444
|
+
}),
|
|
445
|
+
});
|
|
446
|
+
|
|
447
|
+
Filters.build({
|
|
448
|
+
search: {
|
|
449
|
+
filter: {
|
|
450
|
+
role: 'admin',
|
|
451
|
+
active: true,
|
|
452
|
+
'user.name': 'Ada',
|
|
453
|
+
},
|
|
454
|
+
},
|
|
455
|
+
});
|
|
456
|
+
// '?filter.role=admin&filter.active=true&filter.user%7E1name=Ada'
|
|
457
|
+
```
|
|
458
|
+
|
|
459
|
+
Object search key rules:
|
|
460
|
+
|
|
461
|
+
| Rule | Behavior |
|
|
462
|
+
| ---------------- | ----------------------------------------------------------------------------- |
|
|
463
|
+
| Declared objects | Only fields declared with `object(...)` hydrate nested object values. |
|
|
464
|
+
| Dot notation | Object fields serialize as `field.child=value`. |
|
|
465
|
+
| `~` escaping | `~` becomes `~0`. |
|
|
466
|
+
| `.` escaping | `.` becomes `~1`. |
|
|
467
|
+
| URL encoding | Happens after object-key segment escaping. |
|
|
468
|
+
| Collisions | Ambiguous object/scalar collisions throw `UrlKitError` with `invalid-search`. |
|
|
469
|
+
|
|
470
|
+
## Hash
|
|
471
|
+
|
|
472
|
+
Hashes support optional, required, enum, and defaulted values. Parsed and normalized `UrlState` always includes a `hash` property.
|
|
473
|
+
|
|
474
|
+
```ts
|
|
475
|
+
import { enumOf, hash, string, url } from '@cookbook/urlkit';
|
|
476
|
+
|
|
477
|
+
const OptionalHash = hash(enumOf(['intro', 'api']).optional());
|
|
478
|
+
OptionalHash.parse('/docs#api').hash;
|
|
479
|
+
// 'api'
|
|
480
|
+
|
|
481
|
+
const RequiredHash = hash(string().required());
|
|
482
|
+
RequiredHash.parse('/docs#overview').hash;
|
|
483
|
+
// 'overview'
|
|
484
|
+
|
|
485
|
+
const DefaultHash = url({
|
|
486
|
+
path: '/docs',
|
|
487
|
+
hash: enumOf(['overview', 'comments']).default('overview'),
|
|
488
|
+
});
|
|
489
|
+
|
|
490
|
+
DefaultHash.parse('/docs').hash;
|
|
491
|
+
// 'overview'
|
|
492
|
+
|
|
493
|
+
DefaultHash.build({ hash: 'overview' }, { defaults: 'omit' });
|
|
494
|
+
// '/docs'
|
|
495
|
+
```
|
|
496
|
+
|
|
497
|
+
## Safe APIs
|
|
498
|
+
|
|
499
|
+
Safe APIs return discriminated result objects instead of throwing for ordinary validation errors.
|
|
500
|
+
|
|
501
|
+
```ts
|
|
502
|
+
const parsed = UserUrl.safeParse('/users/not-a-number');
|
|
503
|
+
|
|
504
|
+
if (parsed.success) {
|
|
505
|
+
parsed.data.params.id;
|
|
506
|
+
} else {
|
|
507
|
+
parsed.error.code;
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
const normalized = UserUrl.safeNormalize({ params: { id: 'wrong' as never } });
|
|
511
|
+
const request = UserUrl.safeParseRequest(new Request('https://example.com/users/42'));
|
|
512
|
+
```
|
|
513
|
+
|
|
514
|
+
Safe result shape:
|
|
515
|
+
|
|
516
|
+
```ts
|
|
517
|
+
type SafeResult<Data> =
|
|
518
|
+
| { readonly success: true; readonly data: Data }
|
|
519
|
+
| { readonly success: false; readonly error: UrlKitError };
|
|
520
|
+
```
|
|
521
|
+
|
|
522
|
+
## Request parsing
|
|
523
|
+
|
|
524
|
+
`parseRequest` and `safeParseRequest` support web-standard `Request` and request-like `{ url: string }` inputs. Use `baseUrl` for relative request-like URLs.
|
|
525
|
+
|
|
526
|
+
```ts
|
|
527
|
+
UserUrl.parseRequest(new Request('https://example.com/users/42?page=2'));
|
|
528
|
+
|
|
529
|
+
UserUrl.safeParseRequest({ url: '/users/42?page=2' }, { baseUrl: 'https://example.com' });
|
|
530
|
+
```
|
|
531
|
+
|
|
532
|
+
No Express, Hono, Fastify, or framework middleware dependency is required. Framework integrations can pass request URLs or use `normalize` with already-extracted params/search/hash.
|
|
533
|
+
|
|
534
|
+
## Static descriptors
|
|
535
|
+
|
|
536
|
+
Static descriptors are for tooling and router-compatible definitions. They must remain statically analyzable, so do not use runtime builders in static route definitions.
|
|
537
|
+
|
|
538
|
+
Good:
|
|
539
|
+
|
|
540
|
+
```ts
|
|
541
|
+
const searchDescriptor = {
|
|
542
|
+
page: { value: 'int', default: 1 },
|
|
543
|
+
sort: {
|
|
544
|
+
value: { type: 'enum', values: ['newest', 'popular'] },
|
|
545
|
+
default: 'newest',
|
|
546
|
+
},
|
|
547
|
+
} as const;
|
|
548
|
+
```
|
|
549
|
+
|
|
550
|
+
Bad:
|
|
551
|
+
|
|
552
|
+
```ts
|
|
553
|
+
import { int } from '@cookbook/urlkit';
|
|
554
|
+
|
|
555
|
+
const searchDescriptor = {
|
|
556
|
+
page: int().default(1),
|
|
557
|
+
};
|
|
558
|
+
```
|
|
559
|
+
|
|
560
|
+
Compile static descriptors through `@cookbook/urlkit/static`:
|
|
561
|
+
|
|
562
|
+
```ts
|
|
563
|
+
import { compileStaticUrl } from '@cookbook/urlkit/static';
|
|
564
|
+
|
|
565
|
+
const ProductUrl = compileStaticUrl({
|
|
566
|
+
path: '/products/{id:int}',
|
|
567
|
+
search: searchDescriptor,
|
|
568
|
+
hash: ['details', 'reviews'],
|
|
569
|
+
});
|
|
570
|
+
|
|
571
|
+
ProductUrl.parse('/products/42?sort=popular#details');
|
|
572
|
+
```
|
|
573
|
+
|
|
574
|
+
## Router-runtime usage
|
|
575
|
+
|
|
576
|
+
`@cookbook/urlkit/router-runtime` contains framework-agnostic helpers for router packages. It does not define routes, route IDs, route trees, loaders, middleware, components, or hooks.
|
|
577
|
+
|
|
578
|
+
```ts
|
|
579
|
+
import {
|
|
580
|
+
buildSearch,
|
|
581
|
+
createRouteUrlContract,
|
|
582
|
+
parseHash,
|
|
583
|
+
parseSearch,
|
|
584
|
+
patchSearch,
|
|
585
|
+
} from '@cookbook/urlkit/router-runtime';
|
|
586
|
+
|
|
587
|
+
const routeDescriptor = {
|
|
588
|
+
path: '/articles/{slug:regex([a-z0-9-]+)}',
|
|
589
|
+
search: {
|
|
590
|
+
ref: { type: 'one', optional: true },
|
|
591
|
+
page: { value: 'int', default: 1 },
|
|
592
|
+
},
|
|
593
|
+
hash: ['comments', 'share'],
|
|
594
|
+
} as const;
|
|
595
|
+
|
|
596
|
+
const ArticleUrl = createRouteUrlContract(routeDescriptor);
|
|
597
|
+
|
|
598
|
+
ArticleUrl.parse('/articles/post-1?ref=email#comments');
|
|
599
|
+
// Router-runtime params default to raw strings.
|
|
600
|
+
|
|
601
|
+
const parsed = parseSearch('?page=2', { schema: routeDescriptor.search });
|
|
602
|
+
const next = buildSearch({ page: 3 }, { schema: routeDescriptor.search });
|
|
603
|
+
const patched = patchSearch('?page=2&ref=email', { page: 3 }, { schema: routeDescriptor.search });
|
|
604
|
+
const section = parseHash('#comments', routeDescriptor.hash);
|
|
605
|
+
```
|
|
606
|
+
|
|
607
|
+
Additional router-runtime helpers:
|
|
608
|
+
|
|
609
|
+
```ts
|
|
610
|
+
import {
|
|
611
|
+
buildHash,
|
|
612
|
+
normalizeHash,
|
|
613
|
+
omitSearch,
|
|
614
|
+
pickSearch,
|
|
615
|
+
replaceSearch,
|
|
616
|
+
} from '@cookbook/urlkit/router-runtime';
|
|
617
|
+
```
|
|
618
|
+
|
|
619
|
+
Use `{ params: 'parsed' }` with `createRouteUrlContract` when a router wants URLKit to parse `int` and `number` path params to numbers.
|
|
620
|
+
|
|
621
|
+
## Error handling
|
|
622
|
+
|
|
623
|
+
All URLKit validation and descriptor errors use `UrlKitError`.
|
|
624
|
+
|
|
625
|
+
```ts
|
|
626
|
+
import { UrlKitError } from '@cookbook/urlkit';
|
|
627
|
+
|
|
628
|
+
try {
|
|
629
|
+
UserUrl.parse('/users/not-a-number');
|
|
630
|
+
} catch (error) {
|
|
631
|
+
if (error instanceof UrlKitError) {
|
|
632
|
+
console.log(error.code, error.path);
|
|
633
|
+
}
|
|
634
|
+
}
|
|
635
|
+
```
|
|
636
|
+
|
|
637
|
+
| Code | Meaning |
|
|
638
|
+
| -------------------- | -------------------------------------------------------------------------- |
|
|
639
|
+
| `invalid-url` | URL input could not be parsed as a URL. |
|
|
640
|
+
| `path-mismatch` | URL pathname does not satisfy the path contract. |
|
|
641
|
+
| `missing-param` | Required path param is missing. |
|
|
642
|
+
| `invalid-param` | Path param is invalid. |
|
|
643
|
+
| `missing-search` | Required search field is missing. |
|
|
644
|
+
| `invalid-search` | Search value, unknown search behavior, or object search shape is invalid. |
|
|
645
|
+
| `invalid-hash` | Hash value is missing or invalid. |
|
|
646
|
+
| `invalid-descriptor` | Contract/schema/static descriptor is invalid at construction/compile time. |
|
|
647
|
+
|
|
648
|
+
## TypeScript inference
|
|
649
|
+
|
|
650
|
+
URLKit infers path params, pathnames, search values, and hash values from the contract.
|
|
651
|
+
|
|
652
|
+
```ts
|
|
653
|
+
const UserUrl = url({
|
|
654
|
+
path: '/users/{id:int}',
|
|
655
|
+
search: {
|
|
656
|
+
tab: enumOf(['profile', 'settings']).default('profile'),
|
|
657
|
+
},
|
|
658
|
+
hash: enumOf(['activity', 'comments']).optional(),
|
|
659
|
+
});
|
|
660
|
+
|
|
661
|
+
const state = UserUrl.parse('/users/42?tab=settings#activity');
|
|
662
|
+
|
|
663
|
+
state.pathname;
|
|
664
|
+
// `/users/${number}`
|
|
665
|
+
|
|
666
|
+
state.params.id;
|
|
667
|
+
// number
|
|
668
|
+
|
|
669
|
+
state.search.tab;
|
|
670
|
+
// 'profile' | 'settings'
|
|
671
|
+
|
|
672
|
+
state.hash;
|
|
673
|
+
// 'activity' | 'comments' | undefined
|
|
674
|
+
```
|
|
675
|
+
|
|
676
|
+
Pathless contracts use `pathname: string` because they validate search/hash independently of the path.
|
|
677
|
+
|
|
678
|
+
```ts
|
|
679
|
+
const Query = search({ q: string() });
|
|
680
|
+
const state = Query.parse('/anything?q=url');
|
|
681
|
+
|
|
682
|
+
state.pathname;
|
|
683
|
+
// string
|
|
684
|
+
```
|
|
685
|
+
|
|
686
|
+
## Framework boundary
|
|
687
|
+
|
|
688
|
+
URLKit core is intentionally framework-agnostic:
|
|
689
|
+
|
|
690
|
+
- no React APIs
|
|
691
|
+
- no framework middleware
|
|
692
|
+
- no route definitions or route trees
|
|
693
|
+
- no loaders/actions
|
|
694
|
+
- no Express/Hono/Fastify/Next.js adapters
|
|
695
|
+
|
|
696
|
+
Router and framework packages can consume URLKit contracts through serialized URLs, `Request`, request-like `{ url: string }`, or structured `normalize` input.
|
|
697
|
+
|
|
698
|
+
## Testing and development
|
|
699
|
+
|
|
700
|
+
```sh
|
|
701
|
+
pnpm install
|
|
702
|
+
npm run typecheck
|
|
703
|
+
npm test
|
|
704
|
+
npm run build
|
|
705
|
+
```
|
|
706
|
+
|
|
707
|
+
No lint script is currently configured in `package.json`.
|