diff options
author | Luca Casonato <hello@lcas.dev> | 2021-09-08 11:14:29 +0200 |
---|---|---|
committer | GitHub <noreply@github.com> | 2021-09-08 11:14:29 +0200 |
commit | e07f28d301b990ebf534cbb8d5fa9f507475c89f (patch) | |
tree | 6a00f6a0abe0a7833a7d0feadf4e5d8c3509c12e | |
parent | 2de5587547247e3acdffecae1c74caf52a021580 (diff) |
feat: add URLPattern API (#11941)
This adds support for the URLPattern API.
The API is added in --unstable only, as it has not yet shipped in any
browser. It is targeted for shipping in Chrome 95.
Spec: https://wicg.github.io/urlpattern/
Co-authored-by: crowlKats < crowlkats@toaxl.com >
-rw-r--r-- | Cargo.lock | 55 | ||||
-rw-r--r-- | cli/tests/unit/urlpattern_test.ts | 45 | ||||
-rw-r--r-- | ext/url/01_urlpattern.js | 269 | ||||
-rw-r--r-- | ext/url/Cargo.toml | 1 | ||||
-rw-r--r-- | ext/url/README.md | 5 | ||||
-rw-r--r-- | ext/url/internal.d.ts | 4 | ||||
-rw-r--r-- | ext/url/lib.deno_url.d.ts | 136 | ||||
-rw-r--r-- | ext/url/lib.rs | 11 | ||||
-rw-r--r-- | ext/url/urlpattern.rs | 40 | ||||
-rw-r--r-- | runtime/js/99_main.js | 4 | ||||
-rw-r--r-- | tools/wpt/expectation.json | 22 |
11 files changed, 589 insertions, 3 deletions
diff --git a/Cargo.lock b/Cargo.lock index 0ac666b83..0f87da0fd 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -923,6 +923,7 @@ dependencies = [ "percent-encoding", "serde", "serde_repr", + "urlpattern", ] [[package]] @@ -4224,6 +4225,47 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "eeba86d422ce181a719445e51872fa30f1f7413b62becb52e95ec91aa262d85c" [[package]] +name = "unic-char-property" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a8c57a407d9b6fa02b4795eb81c5b6652060a15a7903ea981f3d723e6c0be221" +dependencies = [ + "unic-char-range", +] + +[[package]] +name = "unic-char-range" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0398022d5f700414f6b899e10b8348231abf9173fa93144cbc1a43b9793c1fbc" + +[[package]] +name = "unic-common" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "80d7ff825a6a654ee85a63e80f92f054f904f21e7d12da4e22f9834a4aaa35bc" + +[[package]] +name = "unic-ucd-ident" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e230a37c0381caa9219d67cf063aa3a375ffed5bf541a452db16e744bdab6987" +dependencies = [ + "unic-char-property", + "unic-char-range", + "unic-ucd-version", +] + +[[package]] +name = "unic-ucd-version" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96bd2f2237fe450fcd0a1d2f5f4e91711124f7857ba2e964247776ebeeb7b0c4" +dependencies = [ + "unic-common", +] + +[[package]] name = "unicase" version = "2.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -4291,6 +4333,19 @@ dependencies = [ ] [[package]] +name = "urlpattern" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fbe1da4e25c8758a07ac5b97fe72dec49416ea0783bfa9d6c24793c3a34f1e4e" +dependencies = [ + "derive_more", + "regex", + "serde", + "unic-ucd-ident", + "url", +] + +[[package]] name = "utf-8" version = "0.7.6" source = "registry+https://github.com/rust-lang/crates.io-index" diff --git a/cli/tests/unit/urlpattern_test.ts b/cli/tests/unit/urlpattern_test.ts new file mode 100644 index 000000000..37a662ac1 --- /dev/null +++ b/cli/tests/unit/urlpattern_test.ts @@ -0,0 +1,45 @@ +// Copyright 2018-2021 the Deno authors. All rights reserved. MIT license. +import { assert, assertEquals, unitTest } from "./test_util.ts"; + +unitTest(function urlPatternFromString() { + const pattern = new URLPattern("https://deno.land/foo/:bar"); + assertEquals(pattern.protocol, "https"); + assertEquals(pattern.hostname, "deno.land"); + assertEquals(pattern.pathname, "/foo/:bar"); + + assert(pattern.test("https://deno.land/foo/x")); + assert(!pattern.test("https://deno.com/foo/x")); + const match = pattern.exec("https://deno.land/foo/x"); + assert(match); + assertEquals(match.pathname.input, "/foo/x"); + assertEquals(match.pathname.groups, { bar: "x" }); +}); + +unitTest(function urlPatternFromStringWithBase() { + const pattern = new URLPattern("/foo/:bar", "https://deno.land"); + assertEquals(pattern.protocol, "https"); + assertEquals(pattern.hostname, "deno.land"); + assertEquals(pattern.pathname, "/foo/:bar"); + + assert(pattern.test("https://deno.land/foo/x")); + assert(!pattern.test("https://deno.com/foo/x")); + const match = pattern.exec("https://deno.land/foo/x"); + assert(match); + assertEquals(match.pathname.input, "/foo/x"); + assertEquals(match.pathname.groups, { bar: "x" }); +}); + +unitTest(function urlPatternFromInit() { + const pattern = new URLPattern({ + pathname: "/foo/:bar", + }); + assertEquals(pattern.protocol, "*"); + assertEquals(pattern.hostname, "*"); + assertEquals(pattern.pathname, "/foo/:bar"); + + assert(pattern.test("https://deno.land/foo/x")); + assert(pattern.test("https://deno.com/foo/x")); + assert(!pattern.test("https://deno.com/bar/x")); + + assert(pattern.test({ pathname: "/foo/x" })); +}); diff --git a/ext/url/01_urlpattern.js b/ext/url/01_urlpattern.js new file mode 100644 index 000000000..b6ff9e40e --- /dev/null +++ b/ext/url/01_urlpattern.js @@ -0,0 +1,269 @@ +// Copyright 2018-2021 the Deno authors. All rights reserved. MIT license. + +// @ts-check +/// <reference path="../../core/internal.d.ts" /> +/// <reference path="../../core/lib.deno_core.d.ts" /> +/// <reference path="../webidl/internal.d.ts" /> +/// <reference path="./internal.d.ts" /> +/// <reference path="./lib.deno_url.d.ts" /> + +"use strict"; + +((window) => { + const core = window.Deno.core; + const webidl = window.__bootstrap.webidl; + const { + ArrayPrototypeMap, + ObjectKeys, + ObjectFromEntries, + RegExp, + RegExpPrototypeExec, + RegExpPrototypeTest, + Symbol, + SymbolFor, + TypeError, + } = window.__bootstrap.primordials; + + const _components = Symbol("components"); + + /** + * @typedef Components + * @property {Component} protocol + * @property {Component} username + * @property {Component} password + * @property {Component} hostname + * @property {Component} port + * @property {Component} pathname + * @property {Component} search + * @property {Component} hash + */ + + /** + * @typedef Component + * @property {string} patternString + * @property {RegExp} regexp + * @property {string[]} groupNameList + */ + + class URLPattern { + /** @type {Components} */ + [_components]; + + /** + * @param {URLPatternInput} input + * @param {string} [baseURL] + */ + constructor(input, baseURL = undefined) { + this[webidl.brand] = webidl.brand; + const prefix = "Failed to construct 'URLPattern'"; + webidl.requiredArguments(arguments.length, 1, { prefix }); + input = webidl.converters.URLPatternInput(input, { + prefix, + context: "Argument 1", + }); + if (baseURL !== undefined) { + baseURL = webidl.converters.USVString(baseURL, { + prefix, + context: "Argument 2", + }); + } + + const components = core.opSync("op_urlpattern_parse", input, baseURL); + + for (const key of ObjectKeys(components)) { + try { + components[key].regexp = new RegExp( + components[key].regexpString, + "u", + ); + } catch (e) { + throw new TypeError(`${prefix}: ${key} is invalid; ${e.message}`); + } + } + + this[_components] = components; + } + + get protocol() { + webidl.assertBranded(this, URLPattern); + return this[_components].protocol.patternString; + } + + get username() { + webidl.assertBranded(this, URLPattern); + return this[_components].username.patternString; + } + + get password() { + webidl.assertBranded(this, URLPattern); + return this[_components].password.patternString; + } + + get hostname() { + webidl.assertBranded(this, URLPattern); + return this[_components].hostname.patternString; + } + + get port() { + webidl.assertBranded(this, URLPattern); + return this[_components].port.patternString; + } + + get pathname() { + webidl.assertBranded(this, URLPattern); + return this[_components].pathname.patternString; + } + + get search() { + webidl.assertBranded(this, URLPattern); + return this[_components].search.patternString; + } + + get hash() { + webidl.assertBranded(this, URLPattern); + return this[_components].hash.patternString; + } + + /** + * @param {URLPatternInput} input + * @param {string} [baseURL] + * @returns {boolean} + */ + test(input, baseURL = undefined) { + webidl.assertBranded(this, URLPattern); + const prefix = "Failed to execute 'test' on 'URLPattern'"; + webidl.requiredArguments(arguments.length, 1, { prefix }); + input = webidl.converters.URLPatternInput(input, { + prefix, + context: "Argument 1", + }); + if (baseURL !== undefined) { + baseURL = webidl.converters.USVString(baseURL, { + prefix, + context: "Argument 2", + }); + } + + const res = core.opSync( + "op_urlpattern_process_match_input", + input, + baseURL, + ); + if (res === null) { + return false; + } + + const [values] = res; + + for (const key of ObjectKeys(values)) { + if (!RegExpPrototypeTest(this[_components][key].regexp, values[key])) { + return false; + } + } + + return true; + } + + /** + * @param {URLPatternInput} input + * @param {string} [baseURL] + * @returns {URLPatternResult | null} + */ + exec(input, baseURL = undefined) { + webidl.assertBranded(this, URLPattern); + const prefix = "Failed to execute 'exec' on 'URLPattern'"; + webidl.requiredArguments(arguments.length, 1, { prefix }); + input = webidl.converters.URLPatternInput(input, { + prefix, + context: "Argument 1", + }); + if (baseURL !== undefined) { + baseURL = webidl.converters.USVString(baseURL, { + prefix, + context: "Argument 2", + }); + } + + const res = core.opSync( + "op_urlpattern_process_match_input", + input, + baseURL, + ); + if (res === null) { + return null; + } + + const [values, inputs] = res; + if (inputs[1] === null) { + inputs.pop(); + } + + /** @type {URLPatternResult} */ + const result = { inputs }; + + /** @type {string} */ + for (const key of ObjectKeys(values)) { + /** @type {Component} */ + const component = this[_components][key]; + const input = values[key]; + const match = RegExpPrototypeExec(component.regexp, input); + if (match === null) { + return null; + } + const groupEntries = ArrayPrototypeMap( + component.groupNameList, + (name, i) => [name, match[i + 1] ?? ""], + ); + const groups = ObjectFromEntries(groupEntries); + result[key] = { + input, + groups, + }; + } + + return result; + } + + [SymbolFor("Deno.customInspect")](inspect) { + return `URLPattern ${ + inspect({ + protocol: this.protocol, + username: this.username, + password: this.password, + hostname: this.hostname, + port: this.port, + pathname: this.pathname, + search: this.search, + hash: this.hash, + }) + }`; + } + } + + webidl.configurePrototype(URLPattern); + + webidl.converters.URLPatternInit = webidl + .createDictionaryConverter("URLPatternInit", [ + { key: "protocol", converter: webidl.converters.USVString }, + { key: "username", converter: webidl.converters.USVString }, + { key: "password", converter: webidl.converters.USVString }, + { key: "hostname", converter: webidl.converters.USVString }, + { key: "port", converter: webidl.converters.USVString }, + { key: "pathname", converter: webidl.converters.USVString }, + { key: "search", converter: webidl.converters.USVString }, + { key: "hash", converter: webidl.converters.USVString }, + { key: "baseURL", converter: webidl.converters.USVString }, + ]); + + webidl.converters["URLPatternInput"] = (V, opts) => { + // Union for (URLPatternInit or USVString) + if (typeof V == "object") { + return webidl.converters.URLPatternInit(V, opts); + } + return webidl.converters.USVString(V, opts); + }; + + window.__bootstrap.urlPattern = { + URLPattern, + }; +})(globalThis); diff --git a/ext/url/Cargo.toml b/ext/url/Cargo.toml index 830661015..8ad3a61f1 100644 --- a/ext/url/Cargo.toml +++ b/ext/url/Cargo.toml @@ -19,6 +19,7 @@ idna = "0.2.3" percent-encoding = "2.1.0" serde = { version = "1.0.129", features = ["derive"] } serde_repr = "0.1.7" +urlpattern = "0.1.2" [dev-dependencies] deno_bench_util = { version = "0.10.0", path = "../../bench_util" } diff --git a/ext/url/README.md b/ext/url/README.md index 991dd8b20..519c2823e 100644 --- a/ext/url/README.md +++ b/ext/url/README.md @@ -1,5 +1,6 @@ # deno_url -This crate implements the URL API for Deno. +This crate implements the URL, and URLPattern APIs for Deno. -Spec: https://url.spec.whatwg.org/ +URL Spec: https://url.spec.whatwg.org/ URLPattern Spec: +https://wicg.github.io/urlpattern/ diff --git a/ext/url/internal.d.ts b/ext/url/internal.d.ts index ec2c2688c..45bf670aa 100644 --- a/ext/url/internal.d.ts +++ b/ext/url/internal.d.ts @@ -10,5 +10,9 @@ declare namespace globalThis { URLSearchParams: typeof URLSearchParams; parseUrlEncoded(bytes: Uint8Array): [string, string][]; }; + + declare var urlPattern: { + URLPattern: typeof URLPattern; + }; } } diff --git a/ext/url/lib.deno_url.d.ts b/ext/url/lib.deno_url.d.ts index df7acbe60..b2ef4095f 100644 --- a/ext/url/lib.deno_url.d.ts +++ b/ext/url/lib.deno_url.d.ts @@ -172,3 +172,139 @@ declare class URL { username: string; toJSON(): string; } + +declare interface URLPatternInit { + protocol?: string; + username?: string; + password?: string; + hostname?: string; + port?: string; + pathname?: string; + search?: string; + hash?: string; + baseURL?: string; +} + +declare type URLPatternInput = string | URLPatternInit; + +declare interface URLPatternComponentResult { + input: string; + groups: Record<string, string>; +} + +/** `URLPatternResult` is the object returned from `URLPattern.match`. */ +declare interface URLPatternResult { + /** The inputs provided when matching. */ + inputs: [URLPatternInit] | [URLPatternInit, string]; + + /** The matched result for the `protocol` matcher. */ + protocol: URLPatternComponentResult; + /** The matched result for the `username` matcher. */ + username: URLPatternComponentResult; + /** The matched result for the `password` matcher. */ + password: URLPatternComponentResult; + /** The matched result for the `hostname` matcher. */ + hostname: URLPatternComponentResult; + /** The matched result for the `port` matcher. */ + port: URLPatternComponentResult; + /** The matched result for the `pathname` matcher. */ + pathname: URLPatternComponentResult; + /** The matched result for the `search` matcher. */ + search: URLPatternComponentResult; + /** The matched result for the `hash` matcher. */ + hash: URLPatternComponentResult; +} + +/** + * The URLPattern API provides a web platform primitive for matching URLs based + * on a convenient pattern syntax. + * + * The syntax is based on path-to-regexp. Wildcards, named capture groups, + * regular groups, and group modifiers are all supported. + * + * ```ts + * // Specify the pattern as structured data. + * const pattern = new URLPattern({ pathname: "/users/:user" }); + * const match = pattern.match("/users/joe"); + * console.log(match.pathname.groups.user); // joe + * ``` + * + * ```ts + * // Specify a fully qualified string pattern. + * const pattern = new URLPattern("https://example.com/books/:id"); + * console.log(pattern.test("https://example.com/books/123")); // true + * console.log(pattern.test("https://deno.land/books/123")); // false + * ``` + * + * ```ts + * // Specify a relative string pattern with a base URL. + * const pattern = new URLPattern("/:article", "https://blog.example.com"); + * console.log(pattern.test("https://blog.example.com/article")); // true + * console.log(pattern.test("https://blog.example.com/article/123")); // false + * ``` + */ +declare class URLPattern { + constructor(input: URLPatternInput, baseURL?: string); + + /** + * Test if the given input matches the stored pattern. + * + * The input can either be provided as a url string (with an optional base), + * or as individual components in the form of an object. + * + * ```ts + * const pattern = new URLPattern("https://example.com/books/:id"); + * + * // Test a url string. + * console.log(pattern.test("https://example.com/books/123")); // true + * + * // Test a relative url with a base. + * console.log(pattern.test("/books/123", "https://example.com")); // true + * + * // Test an object of url components. + * console.log(pattern.test({ pathname: "/books/123" })); // true + * ``` + */ + test(input: URLPatternInput, baseURL?: string): boolean; + + /** + * Match the given input against the stored pattern. + * + * The input can either be provided as a url string (with an optional base), + * or as individual components in the form of an object. + * + * ```ts + * const pattern = new URLPattern("https://example.com/books/:id"); + * + * // Match a url string. + * let match = pattern.match("https://example.com/books/123"); + * console.log(match.pathname.groups.id); // 123 + * + * // Match a relative url with a base. + * match = pattern.match("/books/123", "https://example.com"); + * console.log(match.pathname.groups.id); // 123 + * + * // Match an object of url components. + * match = pattern.match({ pathname: "/books/123" }); + * console.log(match.pathname.groups.id); // 123 + * ``` + */ + exec(input: URLPatternInput, baseURL?: string): URLPatternResult | null; + + /** The pattern string for the `protocol`. */ + readonly protocol: string; + /** The pattern string for the `username`. */ + readonly username: string; + /** The pattern string for the `password`. */ + readonly password: string; + /** The pattern string for the `hostname`. */ + readonly hostname: string; + /** The pattern string for the `port`. */ + readonly port: string; + /** The pattern string for the `pathname`. */ + readonly pathname: string; + /** The pattern string for the `search`. */ + readonly search: string; + /** The pattern string for the `hash`. */ + readonly hash: string; +} diff --git a/ext/url/lib.rs b/ext/url/lib.rs index d8987c816..0f8d5c599 100644 --- a/ext/url/lib.rs +++ b/ext/url/lib.rs @@ -1,5 +1,7 @@ // Copyright 2018-2021 the Deno authors. All rights reserved. MIT license. +mod urlpattern; + use deno_core::error::generic_error; use deno_core::error::type_error; use deno_core::error::uri_error; @@ -14,11 +16,15 @@ use deno_core::ZeroCopyBuf; use std::panic::catch_unwind; use std::path::PathBuf; +use crate::urlpattern::op_urlpattern_parse; +use crate::urlpattern::op_urlpattern_process_match_input; + pub fn init() -> Extension { Extension::builder() .js(include_js_files!( prefix "deno:ext/url", "00_url.js", + "01_urlpattern.js", )) .ops(vec![ ("op_url_parse", op_sync(op_url_parse)), @@ -31,6 +37,11 @@ pub fn init() -> Extension { "op_url_stringify_search_params", op_sync(op_url_stringify_search_params), ), + ("op_urlpattern_parse", op_sync(op_urlpattern_parse)), + ( + "op_urlpattern_process_match_input", + op_sync(op_urlpattern_process_match_input), + ), ]) .build() } diff --git a/ext/url/urlpattern.rs b/ext/url/urlpattern.rs new file mode 100644 index 000000000..b9f53665f --- /dev/null +++ b/ext/url/urlpattern.rs @@ -0,0 +1,40 @@ +use deno_core::error::type_error; +use deno_core::error::AnyError; + +use urlpattern::quirks; +use urlpattern::quirks::MatchInput; +use urlpattern::quirks::StringOrInit; +use urlpattern::quirks::UrlPattern; + +pub fn op_urlpattern_parse( + _state: &mut deno_core::OpState, + input: StringOrInit, + base_url: Option<String>, +) -> Result<UrlPattern, AnyError> { + let init = urlpattern::quirks::process_construct_pattern_input( + input, + base_url.as_deref(), + ) + .map_err(|e| type_error(e.to_string()))?; + + let pattern = urlpattern::quirks::parse_pattern(init) + .map_err(|e| type_error(e.to_string()))?; + + Ok(pattern) +} + +pub fn op_urlpattern_process_match_input( + _state: &mut deno_core::OpState, + input: StringOrInit, + base_url: Option<String>, +) -> Result<Option<(MatchInput, quirks::Inputs)>, AnyError> { + let res = urlpattern::quirks::process_match_input(input, base_url.as_deref()) + .map_err(|e| type_error(e.to_string()))?; + + let (input, inputs) = match res { + Some((input, inputs)) => (input, inputs), + None => return Ok(None), + }; + + Ok(urlpattern::quirks::parse_match_input(input).map(|input| (input, inputs))) +} diff --git a/runtime/js/99_main.js b/runtime/js/99_main.js index 59b3a428f..d086a42b2 100644 --- a/runtime/js/99_main.js +++ b/runtime/js/99_main.js @@ -41,6 +41,7 @@ delete Object.prototype.__proto__; const performance = window.__bootstrap.performance; const crypto = window.__bootstrap.crypto; const url = window.__bootstrap.url; + const urlPattern = window.__bootstrap.urlPattern; const headers = window.__bootstrap.headers; const streams = window.__bootstrap.streams; const fileReader = window.__bootstrap.fileReader; @@ -431,8 +432,9 @@ delete Object.prototype.__proto__; }; const unstableWindowOrWorkerGlobalScope = { - WebSocketStream: util.nonEnumerable(webSocket.WebSocketStream), BroadcastChannel: util.nonEnumerable(broadcastChannel.BroadcastChannel), + URLPattern: util.nonEnumerable(urlPattern.URLPattern), + WebSocketStream: util.nonEnumerable(webSocket.WebSocketStream), GPU: util.nonEnumerable(webgpu.GPU), GPUAdapter: util.nonEnumerable(webgpu.GPUAdapter), diff --git a/tools/wpt/expectation.json b/tools/wpt/expectation.json index db3d5b60e..1c0ef521a 100644 --- a/tools/wpt/expectation.json +++ b/tools/wpt/expectation.json @@ -13490,6 +13490,14 @@ ], "toString.tentative.any.html": false, "type.tentative.any.html": false + }, + "function": { + "call.tentative.any.html": false, + "constructor.tentative.any.html": [ + "construct with JS function" + ], + "table.tentative.any.html": false, + "type.tentative.any.html": false } }, "serialization": { @@ -14527,5 +14535,19 @@ "performance.clearResourceTimings in workers", "performance.setResourceTimingBufferSize in workers" ] + }, + "urlpattern": { + "urlpattern.any.html": [ + "Pattern: [{\"pathname\":\"/foo/bar\"}] Inputs: [\"./foo/bar\",\"https://example.com\"]" + ], + "urlpattern.any.worker.html": [ + "Pattern: [{\"pathname\":\"/foo/bar\"}] Inputs: [\"./foo/bar\",\"https://example.com\"]" + ], + "urlpattern.https.any.html": [ + "Pattern: [{\"pathname\":\"/foo/bar\"}] Inputs: [\"./foo/bar\",\"https://example.com\"]" + ], + "urlpattern.https.any.worker.html": [ + "Pattern: [{\"pathname\":\"/foo/bar\"}] Inputs: [\"./foo/bar\",\"https://example.com\"]" + ] } }
\ No newline at end of file |