diff --git a/packages/route-pattern/src/experimental/route-pattern/ast.ts b/packages/route-pattern/src/experimental/route-pattern/ast.ts new file mode 100644 index 00000000000..7a2f94f92cc --- /dev/null +++ b/packages/route-pattern/src/experimental/route-pattern/ast.ts @@ -0,0 +1,21 @@ +import type { PartPattern } from '../part-pattern' + +export type AST = { + protocol: PartPattern + hostname: PartPattern + port: string | null + pathname: PartPattern + + /** + * - `null`: key must be present + * - Empty `Set`: key must be present with a value + * - Non-empty `Set`: key must be present with all these values + * + * ```ts + * new Map([['q', null]]) // -> ?q, ?q=, ?q=1 + * new Map([['q', new Set()]]) // -> ?q=1 + * new Map([['q', new Set(['x', 'y'])]]) // -> ?q=x&q=y + * ``` + */ + search: Map | null> +} diff --git a/packages/route-pattern/src/experimental/route-pattern/index.ts b/packages/route-pattern/src/experimental/route-pattern/index.ts new file mode 100644 index 00000000000..4fecd83d05e --- /dev/null +++ b/packages/route-pattern/src/experimental/route-pattern/index.ts @@ -0,0 +1 @@ +export { RoutePattern } from './route-pattern.ts' diff --git a/packages/route-pattern/src/experimental/route-pattern/join.ts b/packages/route-pattern/src/experimental/route-pattern/join.ts new file mode 100644 index 00000000000..662130ec0f1 --- /dev/null +++ b/packages/route-pattern/src/experimental/route-pattern/join.ts @@ -0,0 +1,116 @@ +import { PartPattern } from '../part-pattern.ts' +import type { AST } from './ast.ts' + +/** + * Joins two pathnames, adding `/` at the join point unless already present. + * + * Conceptually: + * + * ```ts + * pathname('a', 'b') -> 'a/b' + * pathname('a/', 'b') -> 'a/b' + * pathname('a', '/b') -> 'a/b' + * pathname('a/', '/b') -> 'a/b' + * pathname('(a/)', '(/b)') -> '(a/)/(/b)' + * ``` + */ +export function pathname(a: PartPattern, b: PartPattern): PartPattern { + if (a.tokens.length === 0) return b + if (b.tokens.length === 0) return a + + let aLast = a.tokens.at(-1) + let bFirst = b.tokens[0] + + let tokens = a.tokens.slice(0, -1) + let tokenOffset = tokens.length + + if (aLast?.type === 'text' && bFirst?.type === 'text') { + // Note: leading `/` is ignored when parsing pathnames so `/b` is the same as `b` + // so no need to explicitly dedup `/` for `.join('a/', '/b')` as its the same as `.join('a/', 'b')` + let needsSlash = !aLast.text.endsWith('/') && !bFirst.text.startsWith('/') + tokens.push({ type: 'text', text: aLast.text + (needsSlash ? '/' : '') + bFirst.text }) + tokenOffset += 1 + } else if (aLast?.type === 'text') { + let needsSlash = !aLast.text.endsWith('/') + tokens.push({ type: 'text', text: needsSlash ? aLast.text + '/' : aLast.text }) + tokenOffset += 1 + if (bFirst) { + tokens.push(bFirst) + tokenOffset += 1 + } + } else if (bFirst?.type === 'text') { + if (aLast) { + tokens.push(aLast) + tokenOffset += 1 + } + let needsSlash = !bFirst.text.startsWith('/') + tokens.push({ type: 'text', text: (needsSlash ? '/' : '') + bFirst.text }) + tokenOffset += 1 + } else { + if (aLast) { + tokens.push(aLast) + tokenOffset += 1 + } + tokens.push({ type: 'text', text: '/' }) + tokenOffset += 1 + if (bFirst) { + tokens.push(bFirst) + tokenOffset += 1 + } + } + + for (let i = 1; i < b.tokens.length; i++) { + let token = b.tokens[i] + if (token.type === ':' || token.type === '*') { + tokens.push({ ...token, nameIndex: token.nameIndex + a.paramNames.length }) + } else { + tokens.push(token) + } + } + + let paramNames = [...a.paramNames, ...b.paramNames] + + let optionals = new Map(a.optionals) + for (let [begin, end] of b.optionals) { + optionals.set(tokenOffset + begin - 1, tokenOffset + end - 1) + } + + return new PartPattern({ tokens, paramNames, optionals }) +} + +/** + * Joins two search patterns, merging params and their constraints. + * + * Conceptually: + * + * ```ts + * search('?a', '?b') -> '?a&b' + * search('?a=1', '?a=2') -> '?a=1&a=2' + * search('?a=1', '?b=2') -> '?a=1&b=2' + * search('', '?a') -> '?a' + * ``` + */ +export function search(a: AST['search'], b: AST['search']): AST['search'] { + let result: AST['search'] = new Map() + + for (let [name, constraint] of a) { + result.set(name, constraint === null ? null : new Set(constraint)) + } + + for (let [name, constraint] of b) { + let current = result.get(name) + + if (current === null || current === undefined) { + result.set(name, constraint === null ? null : new Set(constraint)) + continue + } + + if (constraint !== null) { + for (let value of constraint) { + current.add(value) + } + } + } + + return result +} diff --git a/packages/route-pattern/src/experimental/route-pattern/parse.ts b/packages/route-pattern/src/experimental/route-pattern/parse.ts new file mode 100644 index 00000000000..d9678ff02a1 --- /dev/null +++ b/packages/route-pattern/src/experimental/route-pattern/parse.ts @@ -0,0 +1,36 @@ +import type { AST } from './ast.ts' + +export function search(source: string): AST['search'] { + let constraints: AST['search'] = new Map() + + for (let param of source.split('&')) { + if (param === '') continue + let equalIndex = param.indexOf('=') + + // `?q` + if (equalIndex === -1) { + let name = decodeURIComponent(param) + if (!constraints.get(name)) { + constraints.set(name, null) + } + continue + } + + let name = decodeURIComponent(param.slice(0, equalIndex)) + let value = decodeURIComponent(param.slice(equalIndex + 1)) + + // `?q=` + if (value.length === 0) { + if (!constraints.get(name)) { + constraints.set(name, new Set()) + } + continue + } + + // `?q=1` + let constraint = constraints.get(name) + constraints.set(name, constraint ? constraint.add(value) : new Set([value])) + } + + return constraints +} diff --git a/packages/route-pattern/src/experimental/route-pattern/route-pattern.test.ts b/packages/route-pattern/src/experimental/route-pattern/route-pattern.test.ts new file mode 100644 index 00000000000..c269b1c7e37 --- /dev/null +++ b/packages/route-pattern/src/experimental/route-pattern/route-pattern.test.ts @@ -0,0 +1,240 @@ +import * as assert from 'node:assert/strict' +import test, { describe } from 'node:test' +import { RoutePattern } from './route-pattern.ts' +import type { AST } from './ast.ts' + +describe('RoutePattern', () => { + describe('parse', () => { + function assertParse( + source: string, + expected: { [K in Exclude]?: string } & { + search?: Record | null> + }, + ) { + let pattern = RoutePattern.parse(source) + let expectedSearch = new Map() + if (expected.search) { + for (let name in expected.search) { + let value = expected.search[name] + expectedSearch.set(name, value ? new Set(expected.search[name]) : null) + } + } + assert.deepStrictEqual( + { + protocol: pattern.ast.protocol?.toString(), + hostname: pattern.ast.hostname?.toString(), + port: pattern.ast.port ?? null, + pathname: pattern.ast.pathname?.toString(), + search: pattern.ast.search, + }, + { + // explicitly set each prop so that we can omitted keys from `expected` to set them as defaults + protocol: expected.protocol ?? '*', + hostname: expected.hostname ?? '*', + port: expected.port ?? null, + pathname: expected.pathname ?? '', + search: expectedSearch, + }, + ) + } + + test('parses hostname', () => { + assertParse('://example.com', { hostname: 'example.com' }) + }) + + test('parses port', () => { + assertParse('://example.com:8000', { hostname: 'example.com', port: '8000' }) + }) + + test('parses pathname', () => { + assertParse('products/:id', { pathname: 'products/:id' }) + }) + + test('parses search', () => { + assertParse('?q', { search: { q: null } }) + assertParse('?q=', { search: { q: [] } }) + assertParse('?q=1', { search: { q: ['1'] } }) + }) + + test('parses protocol + hostname', () => { + assertParse('https://example.com', { + protocol: 'https', + hostname: 'example.com', + }) + }) + + test('parses protocol + pathname', () => { + assertParse('http:///dir/file', { + protocol: 'http', + pathname: 'dir/file', + }) + }) + + test('parses hostname + pathname', () => { + assertParse('://example.com/about', { + hostname: 'example.com', + pathname: 'about', + }) + }) + + test('parses protocol + hostname + pathname', () => { + assertParse('https://example.com/about', { + protocol: 'https', + hostname: 'example.com', + pathname: 'about', + }) + }) + + test('parses protocol + hostname + search', () => { + assertParse('https://example.com?q=1', { + protocol: 'https', + hostname: 'example.com', + search: { q: ['1'] }, + }) + }) + + test('parses protocol + pathname + search', () => { + assertParse('http:///dir/file?q=1', { + protocol: 'http', + pathname: 'dir/file', + search: { q: ['1'] }, + }) + }) + + test('parses hostname + pathname + search', () => { + assertParse('://example.com/about?q=1', { + hostname: 'example.com', + pathname: 'about', + search: { q: ['1'] }, + }) + }) + + test('parses protocol + hostname + pathname + search', () => { + assertParse('https://example.com/about?q=1', { + protocol: 'https', + hostname: 'example.com', + pathname: 'about', + search: { q: ['1'] }, + }) + }) + + test('parses search params into constraints grouped by param name', () => { + assertParse('?q&q', { search: { q: null } }) + assertParse('?q&q=', { search: { q: [] } }) + assertParse('?q&q=1', { search: { q: ['1'] } }) + assertParse('?q=&q=1', { search: { q: ['1'] } }) + assertParse('?q=1&q=2', { search: { q: ['1', '2'] } }) + assertParse('?q&q=&q=1&q=2', { search: { q: ['1', '2'] } }) + }) + }) + + describe('join', () => { + function assertJoin(a: string, b: string, expected: string) { + assert.deepStrictEqual( + RoutePattern.parse(a).join(RoutePattern.parse(b)), + RoutePattern.parse(expected), + ) + } + + test('protocol', () => { + assertJoin('http://', '*://', 'http://') + assertJoin('*://', '*://', '*://') + assertJoin('*://', 'http://', 'http://') + + assertJoin('http://', '*proto://', '*proto://') + assertJoin('*proto://', 'http://', 'http://') + assertJoin('*proto://', '*other://', '*other://') + assertJoin('*://', '*proto://', '*proto://') + + assertJoin('http://', 'https://', 'https://') + assertJoin('://example.com', 'https://', 'https://example.com') + assertJoin('http://example.com', 'https://', 'https://example.com') + }) + + test('hostname', () => { + assertJoin('://example.com', '://*', '://example.com') + assertJoin('://*', '://*', '://*') + assertJoin('://*', '://example.com', '://example.com') + + assertJoin('://example.com', '://*host', '://*host') + assertJoin('://*host', '://example.com', '://example.com') + assertJoin('://*host', '://*other', '://*other') + assertJoin('://*', '://*host', '://*host') + + assertJoin('://example.com', '://other.com', '://other.com') + assertJoin('://', '://other.com', '://other.com') + assertJoin('http://example.com', '://other.com', 'http://other.com') + assertJoin('://example.com/pathname', '://other.com', '://other.com/pathname') + assertJoin('/pathname', '://other.com', '://other.com/pathname') + }) + + test('port', () => { + assertJoin('://:8000', '://', '://:8000') + assertJoin('://', '://:8000', '://:8000') + assertJoin('://:8000', '://:3000', '://:3000') + assertJoin('://example.com', '://example.com:8000', '://example.com:8000') + assertJoin('http://example.com:4321', '://example.com:8000', 'http://example.com:8000') + }) + + test('pathname', () => { + assertJoin('', '', '') + assertJoin('', 'b', 'b') + assertJoin('a', '', 'a') + + assertJoin('a', 'b', 'a/b') + assertJoin('a/', 'b', 'a/b') + assertJoin('a', '/b', 'a/b') + assertJoin('a/', '/b', 'a/b') + + assertJoin('(a/)', 'b', '(a/)/b') + assertJoin('(a/)', '/b', '(a/)/b') + assertJoin('a', '(/b)', 'a/(/b)') + assertJoin('a/', '(/b)', 'a/(/b)') + + assertJoin('(a/)', '(/b)', '(a/)/(/b)') + assertJoin('((a/))', '((/b))', '((a/))/((/b))') + }) + + test('search', () => { + assertJoin('path', '?a', 'path?a') + assertJoin('?a', '?b=1', '?a&b=1') + assertJoin('?a=1', '?b=2', '?a=1&b=2') + }) + + test('combos', () => { + assertJoin('http://example.com/a', '*proto://*host/b', '*proto://*host/a/b') + assertJoin('http://example.com:8000/a', 'https:///b', 'https://example.com:8000/a/b') + assertJoin('http://example.com:8000/a', '://other.com/b', 'http://other.com:8000/a/b') + + assertJoin( + 'https://api.example.com:8000/v1/:resource', + '/users/(admin/)posts?filter&sort=asc', + 'https://api.example.com:8000/v1/:resource/users/(admin/)posts?filter&sort=asc', + ) + + assertJoin( + '*proto://example.com/base', + '*proto://other.com/path', + '*proto://other.com/base/path', + ) + + assertJoin( + 'http://old.com:3000/keep/this', + 'https://new.com:8080', + 'https://new.com:8080/keep/this', + ) + + assertJoin( + 'users/:id?tab=profile', + 'posts/:postId?sort=recent', + 'users/:id/posts/:postId?tab=profile&sort=recent', + ) + + assertJoin( + '://(staging.)example.com/api(/:version)', + '://*/resources/:id(.json)', + '://(staging.)example.com/api(/:version)/resources/:id(.json)', + ) + }) + }) +}) diff --git a/packages/route-pattern/src/experimental/route-pattern/route-pattern.ts b/packages/route-pattern/src/experimental/route-pattern/route-pattern.ts new file mode 100644 index 00000000000..66f55946a79 --- /dev/null +++ b/packages/route-pattern/src/experimental/route-pattern/route-pattern.ts @@ -0,0 +1,49 @@ +import type { AST } from './ast.ts' +import { split } from './split.ts' +import * as Join from './join.ts' +import * as Parse from './parse.ts' +import { PartPattern } from '../part-pattern.ts' + +export class RoutePattern { + readonly ast: AST + + private constructor(ast: AST) { + this.ast = ast + } + + static parse(source: string): RoutePattern { + let spans = split(source) + + return new RoutePattern({ + protocol: spans.protocol + ? PartPattern.parse(source, spans.protocol) + : PartPattern.parse('*', [0, 1]), + hostname: spans.hostname + ? PartPattern.parse(source, spans.hostname) + : PartPattern.parse('*', [0, 1]), + port: spans.port ? source.slice(...spans.port) : null, + pathname: spans.pathname + ? PartPattern.parse(source, spans.pathname) + : PartPattern.parse('', [0, 0]), + search: spans.search ? Parse.search(source.slice(...spans.search)) : new Map(), + }) + } + + join(other: RoutePattern): RoutePattern { + return new RoutePattern({ + protocol: isNamelessWildcard(other.ast.protocol) ? this.ast.protocol : other.ast.protocol, + hostname: isNamelessWildcard(other.ast.hostname) ? this.ast.hostname : other.ast.hostname, + port: other.ast.port ?? this.ast.port, + pathname: Join.pathname(this.ast.pathname, other.ast.pathname), + search: Join.search(this.ast.search, other.ast.search), + }) + } +} + +function isNamelessWildcard(part: PartPattern): boolean { + if (part.tokens.length !== 1) return false + let token = part.tokens[0] + if (token.type !== '*') return false + const name = part.paramNames[token.nameIndex] + return name === '*' +} diff --git a/packages/route-pattern/src/experimental/route-pattern/split.test.ts b/packages/route-pattern/src/experimental/route-pattern/split.test.ts new file mode 100644 index 00000000000..e09b030baf3 --- /dev/null +++ b/packages/route-pattern/src/experimental/route-pattern/split.test.ts @@ -0,0 +1,85 @@ +import * as assert from 'node:assert/strict' +import test, { describe } from 'node:test' +import { split, type SplitResult } from './split.ts' + +function assertSplit(source: string, expected: Partial) { + expected.protocol = expected.protocol ?? null + expected.hostname = expected.hostname ?? null + expected.port = expected.port ?? null + expected.pathname = expected.pathname ?? null + expected.search = expected.search ?? null + + assert.deepStrictEqual(split(source), expected) +} + +describe('split', () => { + test('protocol', () => { + assertSplit('http://', { protocol: [0, 4] }) + }) + + test('hostname', () => { + assertSplit('://example.com', { hostname: [3, 14] }) + }) + + test('port', () => { + assertSplit('://example.com:8000', { hostname: [3, 14], port: [15, 19] }) + }) + + test('pathname', () => { + assertSplit('pathname', { pathname: [0, 8] }) + assertSplit('/pathname', { pathname: [1, 9] }) + assertSplit('//pathname', { pathname: [1, 10] }) + }) + + test('empty pathname', () => { + assertSplit('/', { pathname: null }) + assertSplit('http:///', { protocol: [0, 4], pathname: null }) + assertSplit('://example/', { hostname: [3, 10], pathname: null }) + }) + + test('search', () => { + assertSplit('?q=1', { search: [1, 4] }) + }) + + test('protocol + hostname', () => { + assertSplit('http://example.com', { protocol: [0, 4], hostname: [7, 18] }) + }) + + test('protocol + pathname', () => { + assertSplit('http:///pathname', { protocol: [0, 4], pathname: [8, 16] }) + }) + + test('hostname + pathname', () => { + assertSplit('://example.com/pathname', { hostname: [3, 14], pathname: [15, 23] }) + }) + + test('protocol + hostname + pathname', () => { + assertSplit('http://example.com/pathname', { + protocol: [0, 4], + hostname: [7, 18], + pathname: [19, 27], + }) + }) + + test('protocol + hostname + port + pathname + search', () => { + assertSplit('http://example.com:8000/pathname?q=1', { + protocol: [0, 4], + hostname: [7, 18], + port: [19, 23], + pathname: [24, 32], + search: [33, 36], + }) + }) + + test('/ before ://', () => { + assertSplit('pathname/then://solidus', { pathname: [0, 23] }) + assertSplit('/pathname/then://solidus', { pathname: [1, 24] }) + }) + + test('? before ://', () => { + assertSplit('?search://solidus', { search: [1, 17] }) + }) + test('? before /', () => { + assertSplit('?search/slash', { search: [1, 13] }) + }) +}) diff --git a/packages/route-pattern/src/experimental/route-pattern/split.ts b/packages/route-pattern/src/experimental/route-pattern/split.ts new file mode 100644 index 00000000000..4ed8bb06372 --- /dev/null +++ b/packages/route-pattern/src/experimental/route-pattern/split.ts @@ -0,0 +1,95 @@ +import type { Span } from '../span' + +export type SplitResult = { + protocol: Span | null + hostname: Span | null + port: Span | null + pathname: Span | null + search: Span | null +} + +/** + * Split a route pattern into protocol, hostname, port, pathname, and search + * spans delimited as `protocol://hostname:port/pathname?search`. + * + * Delimiters are not included in the spans with the exception of the leading `/` for pathname. + * Spans are [begin (inclusive), end (exclusive)]. + */ +export function split(source: string): SplitResult { + let result: SplitResult = { + protocol: null, + hostname: null, + port: null, + pathname: null, + search: null, + } + + let questionMarkIndex = source.indexOf('?') + if (questionMarkIndex !== -1) { + result.search = span(questionMarkIndex + 1, source.length) + source = source.slice(0, questionMarkIndex) + } + + let solidusIndex = source.indexOf('://') + + if (solidusIndex === -1) { + // path/without/solidus + result.pathname = pathnameSpan(source, 0, source.length) + return result + } + + let slashIndex = source.indexOf('/') + if (slashIndex === solidusIndex + 1) { + // first slash is from solidus, find next slash + slashIndex = source.indexOf('/', solidusIndex + 3) + } + + if (slashIndex === -1) { + // (protocol)://(host) + result.protocol = span(0, solidusIndex) + const host = span(solidusIndex + 3, source.length) + if (host) { + const { hostname, port } = hostSpans(source, host) + result.hostname = hostname + result.port = port + } + return result + } + + if (slashIndex < solidusIndex) { + // pathname/with://solidus + result.pathname = pathnameSpan(source, 0, source.length) + return result + } + + // (protocol)://(host)/(pathname) + result.protocol = span(0, solidusIndex) + const host = span(solidusIndex + 3, slashIndex) + if (host) { + const { hostname, port } = hostSpans(source, host) + result.hostname = hostname + result.port = port + } + result.pathname = pathnameSpan(source, slashIndex, source.length) + return result +} + +function span(start: number, end: number): Span | null { + if (start === end) return null + return [start, end] +} + +function hostSpans(source: string, host: Span): { hostname: Span | null; port: Span | null } { + let lastColonIndex = source.slice(0, host[1]).lastIndexOf(':') + if (lastColonIndex === -1 || lastColonIndex < host[0]) return { hostname: host, port: null } + + if (source.slice(lastColonIndex + 1, host[1]).match(/^\d+$/)) { + return { hostname: span(host[0], lastColonIndex), port: span(lastColonIndex + 1, host[1]) } + } + return { hostname: host, port: null } +} + +function pathnameSpan(source: string, begin: number, end: number): Span | null { + if (source[begin] === '/') begin += 1 + return span(begin, end) +}