diff --git a/CHANGES.md b/CHANGES.md index eebe2c34..6482b1c8 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -183,6 +183,20 @@ To be released. - This package is primarily used by generated vocabulary classes and provides the runtime infrastructure for ActivityPub object processing. +### @fedify/uri-template + + - Added `@fedify/uri-template` package, a fully RFC 6570 compliant URI + template library for handling ActivityPub URL patterns. + [[#418], [#475] by Lee ByeongJun] + + - Full Level 4 support with all operators and modifiers (explode `*`, + prefix `:n`). + - Support symmetric pattern matching. + - Strict percent-encoding validation and deterministic expansion + following RFC rules. + +[#418]: https://github.com/fedify-dev/fedify/issues/418 +[#475]: https://github.com/fedify-dev/fedify/pull/475 Version 1.10.0 -------------- diff --git a/deno.json b/deno.json index e6cc1281..4639dca5 100644 --- a/deno.json +++ b/deno.json @@ -15,6 +15,7 @@ "./packages/sqlite", "./packages/sveltekit", "./packages/testing", + "./packages/uri-template", "./packages/vocab-runtime", "./packages/relay", "./packages/vocab-tools", diff --git a/deno.lock b/deno.lock index 6be251a2..246cff6f 100644 --- a/deno.lock +++ b/deno.lock @@ -6,40 +6,38 @@ "jsr:@david/dax@~0.43.2": "0.43.2", "jsr:@david/path@0.2": "0.2.0", "jsr:@david/which@~0.4.1": "0.4.1", - "jsr:@es-toolkit/es-toolkit@^1.39.5": "1.40.0", + "jsr:@es-toolkit/es-toolkit@^1.39.5": "1.41.0", "jsr:@hongminhee/localtunnel@0.3": "0.3.0", - "jsr:@hono/hono@^4.7.1": "4.9.10", - "jsr:@hono/hono@^4.8.3": "4.9.10", - "jsr:@logtape/file@^1.1.1": "1.1.1", + "jsr:@hono/hono@^4.7.1": "4.10.4", + "jsr:@hono/hono@^4.8.3": "4.10.4", + "jsr:@logtape/file@^1.1.1": "1.1.2", "jsr:@logtape/logtape@^1.0.4": "1.1.2", "jsr:@logtape/logtape@^1.1.1": "1.1.2", - "jsr:@luca/esbuild-deno-loader@0.11.0": "0.11.0", - "jsr:@optique/core@~0.6.1": "0.6.1", - "jsr:@optique/run@~0.6.1": "0.6.1", + "jsr:@logtape/logtape@^1.1.2": "1.1.2", + "jsr:@optique/core@~0.6.1": "0.6.2", + "jsr:@optique/core@~0.6.2": "0.6.2", + "jsr:@optique/run@~0.6.1": "0.6.2", "jsr:@std/assert@0.224": "0.224.0", "jsr:@std/assert@0.226": "0.226.0", - "jsr:@std/assert@^1.0.13": "1.0.15", - "jsr:@std/async@^1.0.13": "1.0.15", - "jsr:@std/bytes@^1.0.2": "1.0.6", - "jsr:@std/bytes@^1.0.5": "1.0.6", - "jsr:@std/encoding@^1.0.5": "1.0.10", + "jsr:@std/assert@^1.0.13": "1.0.13", + "jsr:@std/async@^1.0.13": "1.0.13", + "jsr:@std/bytes@^1.0.5": "1.0.5", "jsr:@std/fmt@0.224": "0.224.0", "jsr:@std/fmt@1": "1.0.8", "jsr:@std/fs@0.224": "0.224.0", "jsr:@std/fs@1": "1.0.19", "jsr:@std/fs@^1.0.3": "1.0.19", "jsr:@std/internal@0.224": "0.224.0", - "jsr:@std/internal@1": "1.0.12", - "jsr:@std/internal@^1.0.10": "1.0.12", - "jsr:@std/internal@^1.0.12": "1.0.12", - "jsr:@std/internal@^1.0.9": "1.0.12", + "jsr:@std/internal@1": "1.0.9", + "jsr:@std/internal@^1.0.6": "1.0.9", + "jsr:@std/internal@^1.0.9": "1.0.9", "jsr:@std/io@0.225": "0.225.2", "jsr:@std/path@0.224": "0.224.0", - "jsr:@std/path@1": "1.1.2", - "jsr:@std/path@^1.0.6": "1.1.2", - "jsr:@std/path@^1.1.1": "1.1.2", + "jsr:@std/path@1": "1.1.1", + "jsr:@std/path@^1.0.6": "1.1.1", + "jsr:@std/path@^1.1.1": "1.1.1", "jsr:@std/testing@0.224": "0.224.0", - "jsr:@std/yaml@^1.0.8": "1.0.9", + "jsr:@std/yaml@^1.0.8": "1.0.8", "npm:@alinea/suite@~0.6.3": "0.6.3", "npm:@cfworker/json-schema@^4.1.1": "4.1.1", "npm:@cloudflare/workers-types@^4.20250529.0": "4.20251014.0", @@ -138,8 +136,8 @@ "@david/which@0.4.1": { "integrity": "896a682b111f92ab866cc70c5b4afab2f5899d2f9bde31ed00203b9c250f225e" }, - "@es-toolkit/es-toolkit@1.40.0": { - "integrity": "70c05badb2ff623062e8bcee8ab448c98111d6c2b55bc1f794edd54d92869577" + "@es-toolkit/es-toolkit@1.41.0": { + "integrity": "4df54a18e80b869880cee8a8a9ff7a5e1c424a9fd0916dccd38d34686f110071" }, "@hongminhee/localtunnel@0.3.0": { "integrity": "4ad858acd963b5fad45b188d54cf751ac8fbe0aae495e1d3ce607e3730270ff4", @@ -147,33 +145,25 @@ "jsr:@logtape/logtape@^1.0.4" ] }, - "@hono/hono@4.9.10": { - "integrity": "b416cf3bf42e33353e37ea13df409b08bd9a67fabc5fca630f76924fbadb01e5" + "@hono/hono@4.10.4": { + "integrity": "e54d00c4cf994e7ae297d7321793cf940656b9c5e934564c03ffc15499041b9e" }, - "@logtape/file@1.1.1": { - "integrity": "3653e800bd1eb5a728a8409191b42644541bca2aec4101911e9fb8439f1f2536", + "@logtape/file@1.1.2": { + "integrity": "27b4fdb442676ce5254533855622efb1daaad5dfb4859aaa24eb84f5eb196364", "dependencies": [ - "jsr:@logtape/logtape@^1.1.1" + "jsr:@logtape/logtape@^1.1.2" ] }, "@logtape/logtape@1.1.2": { "integrity": "df86a456db8f8a67bea00ce0f8acfc717caf9c3c1ee0c12939d6877796fdd1e6" }, - "@luca/esbuild-deno-loader@0.11.0": { - "integrity": "c05a989aa7c4ee6992a27be5f15cfc5be12834cab7ff84cabb47313737c51a2c", - "dependencies": [ - "jsr:@std/bytes@^1.0.2", - "jsr:@std/encoding", - "jsr:@std/path@^1.0.6" - ] - }, - "@optique/core@0.6.1": { - "integrity": "87fe16d06724b8d83c114c7c734780426c34ff865627cda9ee21cd18ada198af" + "@optique/core@0.6.2": { + "integrity": "da0b7c792c44f8835d16cc211ded2d995c0785aaa54d694da0c0c7140dcb8f1f" }, - "@optique/run@0.6.1": { - "integrity": "5e4017221e22dde1e731a81ccae7c0cf5d40e1f392b78193fe5ccc6475fb88b4", + "@optique/run@0.6.2": { + "integrity": "e90ae47fde75665a1c1ad185318c4ee9bc07d412b9e42554dd4bcdf68fb0551c", "dependencies": [ - "jsr:@optique/core@~0.6.1" + "jsr:@optique/core@~0.6.2" ] }, "@std/assert@0.224.0": { @@ -189,20 +179,17 @@ "jsr:@std/internal@1" ] }, - "@std/assert@1.0.15": { - "integrity": "d64018e951dbdfab9777335ecdb000c0b4e3df036984083be219ce5941e4703b", + "@std/assert@1.0.13": { + "integrity": "ae0d31e41919b12c656c742b22522c32fb26ed0cba32975cb0de2a273cb68b29", "dependencies": [ - "jsr:@std/internal@^1.0.12" + "jsr:@std/internal@^1.0.6" ] }, - "@std/async@1.0.15": { - "integrity": "55d1d9d04f99403fe5730ab16bdcc3c47f658a6bf054cafb38a50f046238116e" - }, - "@std/bytes@1.0.6": { - "integrity": "f6ac6adbd8ccd99314045f5703e23af0a68d7f7e58364b47d2c7f408aeb5820a" + "@std/async@1.0.13": { + "integrity": "1d76ca5d324aef249908f7f7fe0d39aaf53198e5420604a59ab5c035adc97c96" }, - "@std/encoding@1.0.10": { - "integrity": "8783c6384a2d13abd5e9e87a7ae0520a30e9f56aeeaa3bdf910a3eaaf5c811a1" + "@std/bytes@1.0.5": { + "integrity": "4465dd739d7963d964c809202ebea6d5c6b8e3829ef25c6a224290fbb8a1021e" }, "@std/fmt@0.224.0": { "integrity": "e20e9a2312a8b5393272c26191c0a68eda8d2c4b08b046bad1673148f1d69851" @@ -229,22 +216,22 @@ "jsr:@std/fmt@0.224" ] }, - "@std/internal@1.0.12": { - "integrity": "972a634fd5bc34b242024402972cd5143eac68d8dffaca5eaa4dba30ce17b027" + "@std/internal@1.0.9": { + "integrity": "bdfb97f83e4db7a13e8faab26fb1958d1b80cc64366501af78a0aee151696eb8" }, "@std/io@0.225.2": { "integrity": "3c740cd4ee4c082e6cfc86458f47e2ab7cb353dc6234d5e9b1f91a2de5f4d6c7", "dependencies": [ - "jsr:@std/bytes@^1.0.5" + "jsr:@std/bytes" ] }, "@std/path@0.224.0": { "integrity": "55bca6361e5a6d158b9380e82d4981d82d338ec587de02951e2b7c3a24910ee6" }, - "@std/path@1.1.2": { - "integrity": "c0b13b97dfe06546d5e16bf3966b1cadf92e1cc83e56ba5476ad8b498d9e3038", + "@std/path@1.1.1": { + "integrity": "fe00026bd3a7e6a27f73709b83c607798be40e20c81dde655ce34052fd82ec76", "dependencies": [ - "jsr:@std/internal@^1.0.10" + "jsr:@std/internal@^1.0.9" ] }, "@std/testing@0.224.0": { @@ -256,8 +243,8 @@ "jsr:@std/path@0.224" ] }, - "@std/yaml@1.0.9": { - "integrity": "6bad3dc766dd85b4b37eabcba81b6aa4eac7a392792ae29abcfb0f90602d55bb" + "@std/yaml@1.0.8": { + "integrity": "90b8aab62995e929fa0ea5f4151c287275b63e321ac375c35ff7406ca60c169d" } }, "npm": { @@ -1536,8 +1523,8 @@ "@shikijs/vscode-textmate@10.0.2": { "integrity": "sha512-83yeghZ2xxin3Nj8z1NMd/NCuca+gsYXswywDy5bHvwlWL8tpTQmzGeUuHd9FC3E/SBEMvzJRwWEOz5gGes9Qg==" }, - "@sindresorhus/is@7.1.0": { - "integrity": "sha512-7F/yz2IphV39hiS2zB4QYVkivrptHHh0K8qJJd9HhuWSdvf8AN7NpebW3CcDZDBQsUPMoDKWsY2WWgW7bqOcfA==" + "@sindresorhus/is@7.1.1": { + "integrity": "sha512-rO92VvpgMc3kfiTjGT52LEtJ8Yc5kCWhZjLQ3LwlA4pSgPpQO7bVpYXParOD8Jwf+cVQECJo3yP/4I8aZtUQTQ==" }, "@speed-highlight/core@1.2.8": { "integrity": "sha512-IGytNtnUnPIobIbOq5Y6LIlqiHNX+vnToQIS7lj6L5819C+rA8TXRDkkG8vePsiBOGcoW9R6i+dp2YBUKdB09Q==" @@ -4167,249 +4154,6 @@ "integrity": "sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==" } }, - "redirects": { - "https://esm.sh/@types/babel__helper-validator-identifier@~7.15.2/index.d.ts": "https://esm.sh/@types/babel__helper-validator-identifier@7.15.2/index.d.ts" - }, - "remote": { - "https://deno.land/std@0.216.0/assert/_constants.ts": "a271e8ef5a573f1df8e822a6eb9d09df064ad66a4390f21b3e31f820a38e0975", - "https://deno.land/std@0.216.0/assert/_diff.ts": "dcc63d94ca289aec80644030cf88ccbf7acaa6fbd7b0f22add93616b36593840", - "https://deno.land/std@0.216.0/assert/_format.ts": "0ba808961bf678437fb486b56405b6fefad2cf87b5809667c781ddee8c32aff4", - "https://deno.land/std@0.216.0/assert/assert.ts": "bec068b2fccdd434c138a555b19a2c2393b71dfaada02b7d568a01541e67cdc5", - "https://deno.land/std@0.216.0/assert/assert_almost_equals.ts": "8b96b7385cc117668b0720115eb6ee73d04c9bcb2f5d2344d674918c9113688f", - "https://deno.land/std@0.216.0/assert/assert_array_includes.ts": "1688d76317fd45b7e93ef9e2765f112fdf2b7c9821016cdfb380b9445374aed1", - "https://deno.land/std@0.216.0/assert/assert_equals.ts": "4497c56fe7d2993b0d447926702802fc0becb44e319079e8eca39b482ee01b4e", - "https://deno.land/std@0.216.0/assert/assert_exists.ts": "24a7bf965e634f909242cd09fbaf38bde6b791128ece08e33ab08586a7cc55c9", - "https://deno.land/std@0.216.0/assert/assert_false.ts": "6f382568e5128c0f855e5f7dbda8624c1ed9af4fcc33ef4a9afeeedcdce99769", - "https://deno.land/std@0.216.0/assert/assert_greater.ts": "4945cf5729f1a38874d7e589e0fe5cc5cd5abe5573ca2ddca9d3791aa891856c", - "https://deno.land/std@0.216.0/assert/assert_greater_or_equal.ts": "573ed8823283b8d94b7443eb69a849a3c369a8eb9666b2d1db50c33763a5d219", - "https://deno.land/std@0.216.0/assert/assert_instance_of.ts": "72dc1faff1e248692d873c89382fa1579dd7b53b56d52f37f9874a75b11ba444", - "https://deno.land/std@0.216.0/assert/assert_is_error.ts": "6596f2b5ba89ba2fe9b074f75e9318cda97a2381e59d476812e30077fbdb6ed2", - "https://deno.land/std@0.216.0/assert/assert_less.ts": "2b4b3fe7910f65f7be52212f19c3977ecb8ba5b2d6d0a296c83cde42920bb005", - "https://deno.land/std@0.216.0/assert/assert_less_or_equal.ts": "b93d212fe669fbde959e35b3437ac9a4468f2e6b77377e7b6ea2cfdd825d38a0", - "https://deno.land/std@0.216.0/assert/assert_match.ts": "ec2d9680ed3e7b9746ec57ec923a17eef6d476202f339ad91d22277d7f1d16e1", - "https://deno.land/std@0.216.0/assert/assert_not_equals.ts": "ac86413ab70ffb14fdfc41740ba579a983fe355ba0ce4a9ab685e6b8e7f6a250", - "https://deno.land/std@0.216.0/assert/assert_not_instance_of.ts": "8f720d92d83775c40b2542a8d76c60c2d4aeddaf8713c8d11df8984af2604931", - "https://deno.land/std@0.216.0/assert/assert_not_match.ts": "b4b7c77f146963e2b673c1ce4846473703409eb93f5ab0eb60f6e6f8aeffe39f", - "https://deno.land/std@0.216.0/assert/assert_not_strict_equals.ts": "da0b8ab60a45d5a9371088378e5313f624799470c3b54c76e8b8abeec40a77be", - "https://deno.land/std@0.216.0/assert/assert_object_match.ts": "e85e5eef62a56ce364c3afdd27978ccab979288a3e772e6855c270a7b118fa49", - "https://deno.land/std@0.216.0/assert/assert_rejects.ts": "e9e0c8d9c3e164c7ac962c37b3be50577c5a2010db107ed272c4c1afb1269f54", - "https://deno.land/std@0.216.0/assert/assert_strict_equals.ts": "0425a98f70badccb151644c902384c12771a93e65f8ff610244b8147b03a2366", - "https://deno.land/std@0.216.0/assert/assert_string_includes.ts": "dfb072a890167146f8e5bdd6fde887ce4657098e9f71f12716ef37f35fb6f4a7", - "https://deno.land/std@0.216.0/assert/assert_throws.ts": "edddd86b39606c342164b49ad88dd39a26e72a26655e07545d172f164b617fa7", - "https://deno.land/std@0.216.0/assert/assertion_error.ts": "9f689a101ee586c4ce92f52fa7ddd362e86434ffdf1f848e45987dc7689976b8", - "https://deno.land/std@0.216.0/assert/equal.ts": "fae5e8a52a11d3ac694bbe1a53e13a7969e3f60791262312e91a3e741ae519e2", - "https://deno.land/std@0.216.0/assert/fail.ts": "f310e51992bac8e54f5fd8e44d098638434b2edb802383690e0d7a9be1979f1c", - "https://deno.land/std@0.216.0/assert/mod.ts": "325df8c0683ad83a873b9691aa66b812d6275fc9fec0b2d180ac68a2c5efed3b", - "https://deno.land/std@0.216.0/assert/unimplemented.ts": "47ca67d1c6dc53abd0bd729b71a31e0825fc452dbcd4fde4ca06789d5644e7fd", - "https://deno.land/std@0.216.0/assert/unreachable.ts": "38cfecb95d8b06906022d2f9474794fca4161a994f83354fd079cac9032b5145", - "https://deno.land/std@0.216.0/async/delay.ts": "8e1d18fe8b28ff95885e2bc54eccec1713f57f756053576d8228e6ca110793ad", - "https://deno.land/std@0.216.0/datetime/constants.ts": "5c198b3b47fbcc4d913e61dcae1c37e053937affc2c9a6a5ad7e5473bab3e4a6", - "https://deno.land/std@0.216.0/encoding/_util.ts": "beacef316c1255da9bc8e95afb1fa56ed69baef919c88dc06ae6cb7a6103d376", - "https://deno.land/std@0.216.0/encoding/hex.ts": "4d47d3b25103cf81a2ed38f54b394d39a77b63338e1eaa04b70c614cb45ec2e6", - "https://deno.land/std@0.216.0/flags/mod.ts": "9f13f3a49c54618277ac49195af934f1c7d235731bcf80fd33b8b234e6839ce9", - "https://deno.land/std@0.216.0/fmt/colors.ts": "d239d84620b921ea520125d778947881f62c50e78deef2657073840b8af9559a", - "https://deno.land/std@0.216.0/fs/_create_walk_entry.ts": "5d9d2aaec05bcf09a06748b1684224d33eba7a4de24cf4cf5599991ca6b5b412", - "https://deno.land/std@0.216.0/fs/_get_file_info_type.ts": "da7bec18a7661dba360a1db475b826b18977582ce6fc9b25f3d4ee0403fe8cbd", - "https://deno.land/std@0.216.0/fs/_is_same_path.ts": "709c95868345fea051c58b9e96af95cff94e6ae98dfcff2b66dee0c212c4221f", - "https://deno.land/std@0.216.0/fs/_is_subdir.ts": "c68b309d46cc8568ed83c000f608a61bbdba0943b7524e7a30f9e450cf67eecd", - "https://deno.land/std@0.216.0/fs/_to_path_string.ts": "29bfc9c6c112254961d75cbf6ba814d6de5349767818eb93090cecfa9665591e", - "https://deno.land/std@0.216.0/fs/copy.ts": "dc0f68c4b6c3b090bfdb909387e309f6169b746bd713927c9507c9ef545d71f6", - "https://deno.land/std@0.216.0/fs/empty_dir.ts": "4f01e6d56e2aa8d90ad60f20bc25601f516b00f6c3044cdf6863a058791d91aa", - "https://deno.land/std@0.216.0/fs/ensure_dir.ts": "dffff68de0d10799b5aa9e39dec4e327e12bbd29e762292193684542648c4aeb", - "https://deno.land/std@0.216.0/fs/ensure_file.ts": "ac5cfde94786b0284d2c8e9f7f9425269bea1b2140612b4aea1f20b508870f59", - "https://deno.land/std@0.216.0/fs/ensure_link.ts": "d42af2edefeaa9817873ec6e46dc5d209ac4d744f8c69c5ecc2dffade78465b6", - "https://deno.land/std@0.216.0/fs/ensure_symlink.ts": "aee3f1655700f60090b4a3037f5b6c07ab37c36807cccad746ce89987719e6d2", - "https://deno.land/std@0.216.0/fs/eol.ts": "c9807291f78361d49fd986a9be04654610c615c5e2ec63d748976197d30ff206", - "https://deno.land/std@0.216.0/fs/exists.ts": "d2757ef764eaf5c6c5af7228e8447db2de42ab084a2dae540097f905723d83f5", - "https://deno.land/std@0.216.0/fs/expand_glob.ts": "a1ce02b05ed7b96985b0665067c9f1018f3f2ade7ee0fb0d629231050260b158", - "https://deno.land/std@0.216.0/fs/mod.ts": "107f5afa4424c2d3ce2f7e9266173198da30302c69af662c720115fe504dc5ee", - "https://deno.land/std@0.216.0/fs/move.ts": "39e0d7ccb88a566d20b949712020e766b15ef1ec19159573d11f949bd677909c", - "https://deno.land/std@0.216.0/fs/walk.ts": "78e1d01a9f75715614bf8d6e58bd77d9fafb1222c41194e607cd3849d7a0e771", - "https://deno.land/std@0.216.0/http/server.ts": "6dce295abc169d0956ae00432441331b3425afad4d79e8b3475739be2f04d614", - "https://deno.land/std@0.216.0/http/status.ts": "ed61b4882af2514a81aefd3245e8df4c47b9a8e54929a903577643d2d1ebf514", - "https://deno.land/std@0.216.0/json/common.ts": "33f1a4f39a45e2f9f357823fd0b5cf88b63fb4784b8c9a28f8120f70a20b23e9", - "https://deno.land/std@0.216.0/jsonc/mod.ts": "82722888823e1af5a8f7918bf810ea581f68081064d529218533acad6cb7c2bc", - "https://deno.land/std@0.216.0/jsonc/parse.ts": "747a0753289fdbfcb9cb86b709b56348c98abc107fbb0a7f350b87af4425a76a", - "https://deno.land/std@0.216.0/media_types/_db.ts": "1d695d9fe1c785e523d6de7191b33f33ecc7866db77358a4f966221cca56e2f9", - "https://deno.land/std@0.216.0/media_types/_util.ts": "afd54920a8a81add64248897898d3f30ca414a8803fe0c4e6d2893a3f6bd32b0", - "https://deno.land/std@0.216.0/media_types/content_type.ts": "ed3f2e1f243b418ad3f441edc95fd92efbadb0f9bde36219c7564c67f9639513", - "https://deno.land/std@0.216.0/media_types/format_media_type.ts": "ffef4718afa2489530cb94021bb865a466eb02037609f7e82899c017959d288a", - "https://deno.land/std@0.216.0/media_types/get_charset.ts": "bce5c0343c14590516cbfa1a3e80891d9a7a53bc9b4a9010061cc4f879e18f6c", - "https://deno.land/std@0.216.0/media_types/parse_media_type.ts": "487f000a38c230ccbac25420a50f600862e06796d0eee19d19631b9e84ee9654", - "https://deno.land/std@0.216.0/media_types/type_by_extension.ts": "bf4e3f5d6b58b624d5daa01cbb8b1e86d9939940a77e7c26e796a075b60ec73b", - "https://deno.land/std@0.216.0/media_types/vendor/mime-db.v1.52.0.ts": "0218d2c7d900e8cd6fa4a866e0c387712af4af9a1bae55d6b2546c73d273a1e6", - "https://deno.land/std@0.216.0/path/_common/assert_path.ts": "2ca275f36ac1788b2acb60fb2b79cb06027198bc2ba6fb7e163efaedde98c297", - "https://deno.land/std@0.216.0/path/_common/basename.ts": "569744855bc8445f3a56087fd2aed56bdad39da971a8d92b138c9913aecc5fa2", - "https://deno.land/std@0.216.0/path/_common/common.ts": "6157c7ec1f4db2b4a9a187efd6ce76dcaf1e61cfd49f87e40d4ea102818df031", - "https://deno.land/std@0.216.0/path/_common/constants.ts": "dc5f8057159f4b48cd304eb3027e42f1148cf4df1fb4240774d3492b5d12ac0c", - "https://deno.land/std@0.216.0/path/_common/dirname.ts": "684df4aa71a04bbcc346c692c8485594fc8a90b9408dfbc26ff32cf3e0c98cc8", - "https://deno.land/std@0.216.0/path/_common/format.ts": "92500e91ea5de21c97f5fe91e178bae62af524b72d5fcd246d6d60ae4bcada8b", - "https://deno.land/std@0.216.0/path/_common/from_file_url.ts": "d672bdeebc11bf80e99bf266f886c70963107bdd31134c4e249eef51133ceccf", - "https://deno.land/std@0.216.0/path/_common/glob_to_reg_exp.ts": "2007aa87bed6eb2c8ae8381adcc3125027543d9ec347713c1ad2c68427330770", - "https://deno.land/std@0.216.0/path/_common/normalize.ts": "684df4aa71a04bbcc346c692c8485594fc8a90b9408dfbc26ff32cf3e0c98cc8", - "https://deno.land/std@0.216.0/path/_common/normalize_string.ts": "dfdf657a1b1a7db7999f7c575ee7e6b0551d9c20f19486c6c3f5ff428384c965", - "https://deno.land/std@0.216.0/path/_common/relative.ts": "faa2753d9b32320ed4ada0733261e3357c186e5705678d9dd08b97527deae607", - "https://deno.land/std@0.216.0/path/_common/strip_trailing_separators.ts": "7024a93447efcdcfeaa9339a98fa63ef9d53de363f1fbe9858970f1bba02655a", - "https://deno.land/std@0.216.0/path/_common/to_file_url.ts": "7f76adbc83ece1bba173e6e98a27c647712cab773d3f8cbe0398b74afc817883", - "https://deno.land/std@0.216.0/path/_interface.ts": "a1419fcf45c0ceb8acdccc94394e3e94f99e18cfd32d509aab514c8841799600", - "https://deno.land/std@0.216.0/path/_os.ts": "8fb9b90fb6b753bd8c77cfd8a33c2ff6c5f5bc185f50de8ca4ac6a05710b2c15", - "https://deno.land/std@0.216.0/path/basename.ts": "5d341aadb7ada266e2280561692c165771d071c98746fcb66da928870cd47668", - "https://deno.land/std@0.216.0/path/common.ts": "03e52e22882402c986fe97ca3b5bb4263c2aa811c515ce84584b23bac4cc2643", - "https://deno.land/std@0.216.0/path/constants.ts": "0c206169ca104938ede9da48ac952de288f23343304a1c3cb6ec7625e7325f36", - "https://deno.land/std@0.216.0/path/dirname.ts": "85bd955bf31d62c9aafdd7ff561c4b5fb587d11a9a5a45e2b01aedffa4238a7c", - "https://deno.land/std@0.216.0/path/extname.ts": "593303db8ae8c865cbd9ceec6e55d4b9ac5410c1e276bfd3131916591b954441", - "https://deno.land/std@0.216.0/path/format.ts": "98fad25f1af7b96a48efb5b67378fcc8ed77be895df8b9c733b86411632162af", - "https://deno.land/std@0.216.0/path/from_file_url.ts": "911833ae4fd10a1c84f6271f36151ab785955849117dc48c6e43b929504ee069", - "https://deno.land/std@0.216.0/path/glob_to_regexp.ts": "5e51f78a0248c75464bf1d49173de3ec2c032880a530578e56b3fed2a57e69d3", - "https://deno.land/std@0.216.0/path/is_absolute.ts": "4791afc8bfd0c87f0526eaa616b0d16e7b3ab6a65b62942e50eac68de4ef67d7", - "https://deno.land/std@0.216.0/path/is_glob.ts": "a65f6195d3058c3050ab905705891b412ff942a292bcbaa1a807a74439a14141", - "https://deno.land/std@0.216.0/path/join.ts": "ae2ec5ca44c7e84a235fd532e4a0116bfb1f2368b394db1c4fb75e3c0f26a33a", - "https://deno.land/std@0.216.0/path/join_globs.ts": "5b3bf248b93247194f94fa6947b612ab9d3abd571ca8386cf7789038545e54a0", - "https://deno.land/std@0.216.0/path/mod.ts": "6f856db94f6a72fc2cf69e0a85eb523aee6a3cd274470717e96f4bee0c9fe329", - "https://deno.land/std@0.216.0/path/normalize.ts": "4155743ccceeed319b350c1e62e931600272fad8ad00c417b91df093867a8352", - "https://deno.land/std@0.216.0/path/normalize_glob.ts": "cc89a77a7d3b1d01053b9dcd59462b75482b11e9068ae6c754b5cf5d794b374f", - "https://deno.land/std@0.216.0/path/parse.ts": "65e8e285f1a63b714e19ef24b68f56e76934c3df0b6e65fd440d3991f4f8aefb", - "https://deno.land/std@0.216.0/path/posix/_util.ts": "1e3937da30f080bfc99fe45d7ed23c47dd8585c5e473b2d771380d3a6937cf9d", - "https://deno.land/std@0.216.0/path/posix/basename.ts": "39ee27a29f1f35935d3603ccf01d53f3d6e0c5d4d0f84421e65bd1afeff42843", - "https://deno.land/std@0.216.0/path/posix/common.ts": "26f60ccc8b2cac3e1613000c23ac5a7d392715d479e5be413473a37903a2b5d4", - "https://deno.land/std@0.216.0/path/posix/constants.ts": "93481efb98cdffa4c719c22a0182b994e5a6aed3047e1962f6c2c75b7592bef1", - "https://deno.land/std@0.216.0/path/posix/dirname.ts": "6535d2bdd566118963537b9dda8867ba9e2a361015540dc91f5afbb65c0cce8b", - "https://deno.land/std@0.216.0/path/posix/extname.ts": "8d36ae0082063c5e1191639699e6f77d3acf501600a3d87b74943f0ae5327427", - "https://deno.land/std@0.216.0/path/posix/format.ts": "185e9ee2091a42dd39e2a3b8e4925370ee8407572cee1ae52838aed96310c5c1", - "https://deno.land/std@0.216.0/path/posix/from_file_url.ts": "951aee3a2c46fd0ed488899d024c6352b59154c70552e90885ed0c2ab699bc40", - "https://deno.land/std@0.216.0/path/posix/glob_to_regexp.ts": "54d3ff40f309e3732ab6e5b19d7111d2d415248bcd35b67a99defcbc1972e697", - "https://deno.land/std@0.216.0/path/posix/is_absolute.ts": "cebe561ad0ae294f0ce0365a1879dcfca8abd872821519b4fcc8d8967f888ede", - "https://deno.land/std@0.216.0/path/posix/is_glob.ts": "8a8b08c08bf731acf2c1232218f1f45a11131bc01de81e5f803450a5914434b9", - "https://deno.land/std@0.216.0/path/posix/join.ts": "aef88d5fa3650f7516730865dbb951594d1a955b785e2450dbee93b8e32694f3", - "https://deno.land/std@0.216.0/path/posix/join_globs.ts": "f6e2619c196b82d8fd67ba2cf680e5b44461f38bdfeec26d7b3f55bd92a85988", - "https://deno.land/std@0.216.0/path/posix/mod.ts": "2301fc1c54a28b349e20656f68a85f75befa0ee9b6cd75bfac3da5aca9c3f604", - "https://deno.land/std@0.216.0/path/posix/normalize.ts": "baeb49816a8299f90a0237d214cef46f00ba3e95c0d2ceb74205a6a584b58a91", - "https://deno.land/std@0.216.0/path/posix/normalize_glob.ts": "41b477068deb832df7f51d6e2b3c0bc274d20919e20c5240d785ba535572d3d0", - "https://deno.land/std@0.216.0/path/posix/parse.ts": "d5bac4eb21262ab168eead7e2196cb862940c84cee572eafedd12a0d34adc8fb", - "https://deno.land/std@0.216.0/path/posix/relative.ts": "3907d6eda41f0ff723d336125a1ad4349112cd4d48f693859980314d5b9da31c", - "https://deno.land/std@0.216.0/path/posix/resolve.ts": "bac20d9921beebbbb2b73706683b518b1d0c1b1da514140cee409e90d6b2913a", - "https://deno.land/std@0.216.0/path/posix/to_file_url.ts": "7aa752ba66a35049e0e4a4be5a0a31ac6b645257d2e031142abb1854de250aaf", - "https://deno.land/std@0.216.0/path/posix/to_namespaced_path.ts": "28b216b3c76f892a4dca9734ff1cc0045d135532bfd9c435ae4858bfa5a2ebf0", - "https://deno.land/std@0.216.0/path/relative.ts": "ab739d727180ed8727e34ed71d976912461d98e2b76de3d3de834c1066667add", - "https://deno.land/std@0.216.0/path/resolve.ts": "a6f977bdb4272e79d8d0ed4333e3d71367cc3926acf15ac271f1d059c8494d8d", - "https://deno.land/std@0.216.0/path/to_file_url.ts": "88f049b769bce411e2d2db5bd9e6fd9a185a5fbd6b9f5ad8f52bef517c4ece1b", - "https://deno.land/std@0.216.0/path/to_namespaced_path.ts": "b706a4103b104cfadc09600a5f838c2ba94dbcdb642344557122dda444526e40", - "https://deno.land/std@0.216.0/path/windows/_util.ts": "d5f47363e5293fced22c984550d5e70e98e266cc3f31769e1710511803d04808", - "https://deno.land/std@0.216.0/path/windows/basename.ts": "e2dbf31d1d6385bfab1ce38c333aa290b6d7ae9e0ecb8234a654e583cf22f8fe", - "https://deno.land/std@0.216.0/path/windows/common.ts": "26f60ccc8b2cac3e1613000c23ac5a7d392715d479e5be413473a37903a2b5d4", - "https://deno.land/std@0.216.0/path/windows/constants.ts": "5afaac0a1f67b68b0a380a4ef391bf59feb55856aa8c60dfc01bd3b6abb813f5", - "https://deno.land/std@0.216.0/path/windows/dirname.ts": "33e421be5a5558a1346a48e74c330b8e560be7424ed7684ea03c12c21b627bc9", - "https://deno.land/std@0.216.0/path/windows/extname.ts": "165a61b00d781257fda1e9606a48c78b06815385e7d703232548dbfc95346bef", - "https://deno.land/std@0.216.0/path/windows/format.ts": "bbb5ecf379305b472b1082cd2fdc010e44a0020030414974d6029be9ad52aeb6", - "https://deno.land/std@0.216.0/path/windows/from_file_url.ts": "ced2d587b6dff18f963f269d745c4a599cf82b0c4007356bd957cb4cb52efc01", - "https://deno.land/std@0.216.0/path/windows/glob_to_regexp.ts": "6dcd1242bd8907aa9660cbdd7c93446e6927b201112b0cba37ca5d80f81be51b", - "https://deno.land/std@0.216.0/path/windows/is_absolute.ts": "4a8f6853f8598cf91a835f41abed42112cebab09478b072e4beb00ec81f8ca8a", - "https://deno.land/std@0.216.0/path/windows/is_glob.ts": "8a8b08c08bf731acf2c1232218f1f45a11131bc01de81e5f803450a5914434b9", - "https://deno.land/std@0.216.0/path/windows/join.ts": "e0b3356615c1a75c56ebb6a7311157911659e11fd533d80d724800126b761ac3", - "https://deno.land/std@0.216.0/path/windows/join_globs.ts": "f6e2619c196b82d8fd67ba2cf680e5b44461f38bdfeec26d7b3f55bd92a85988", - "https://deno.land/std@0.216.0/path/windows/mod.ts": "2301fc1c54a28b349e20656f68a85f75befa0ee9b6cd75bfac3da5aca9c3f604", - "https://deno.land/std@0.216.0/path/windows/normalize.ts": "78126170ab917f0ca355a9af9e65ad6bfa5be14d574c5fb09bb1920f52577780", - "https://deno.land/std@0.216.0/path/windows/normalize_glob.ts": "c57c186b0785ba5320a85e573c264f42c46eb1d0a4a78611f4791a7083baaa17", - "https://deno.land/std@0.216.0/path/windows/parse.ts": "b9239edd892a06a06625c1b58425e199f018ce5649ace024d144495c984da734", - "https://deno.land/std@0.216.0/path/windows/relative.ts": "3e1abc7977ee6cc0db2730d1f9cb38be87b0ce4806759d271a70e4997fc638d7", - "https://deno.land/std@0.216.0/path/windows/resolve.ts": "75b2e3e1238d840782cee3d8864d82bfaa593c7af8b22f19c6422cf82f330ab3", - "https://deno.land/std@0.216.0/path/windows/to_file_url.ts": "1cd63fd35ec8d1370feaa4752eccc4cc05ea5362a878be8dc7db733650995484", - "https://deno.land/std@0.216.0/path/windows/to_namespaced_path.ts": "4ffa4fb6fae321448d5fe810b3ca741d84df4d7897e61ee29be961a6aac89a4c", - "https://deno.land/std@0.216.0/regexp/escape.ts": "57303d6c9c6aa058d9a79a3c70113c545391639a87e9a18428220c1bad407549", - "https://deno.land/std@0.216.0/semver/_comparator_format.ts": "b5a56b999670c0b3a3e8ad437ca0fafbfce0e973bba6769979d7665e318e8b6c", - "https://deno.land/std@0.216.0/semver/_comparator_intersects.ts": "65b744d76b3be4ed91afd149754f530681032890d32fd65d02faf8ef96491cb7", - "https://deno.land/std@0.216.0/semver/_comparator_max.ts": "c3a97d5b43b9104afa3687780500ef79b69ae5107cee2359004f80ea48969c7d", - "https://deno.land/std@0.216.0/semver/_comparator_min.ts": "080a9939b177d64904e1772da02dc4673f9cd1b3c9ae1a5c534cfdf4bb3ee9af", - "https://deno.land/std@0.216.0/semver/_constants.ts": "90879e4ea94a34c49c8ecea3d9c06e13e61ecb7caca80c8f289139440ca9835a", - "https://deno.land/std@0.216.0/semver/_is_comparator.ts": "d032762a6993b7cd3e4243cbeded0eeab70773dff960d4e92af8770550eea7b6", - "https://deno.land/std@0.216.0/semver/_parse_comparator.ts": "2dfa7f08da84038f8e2c50d629a329b2870a096791fd1f299a00de3bb547c34a", - "https://deno.land/std@0.216.0/semver/_shared.ts": "8d44684775cde4a64bd6bdb99b51f3bc59ed65f10af78ca136cc2eab3f6716f1", - "https://deno.land/std@0.216.0/semver/can_parse.ts": "d4a26f74be078f3ab10293b07bf022021a2f362b3e21b58422c214e7268110b2", - "https://deno.land/std@0.216.0/semver/compare.ts": "e8871844a35cc8fe16e883c16e5237e06a93aa4830ae10d06501abe63586fc57", - "https://deno.land/std@0.216.0/semver/constants.ts": "04c8625428552e967c85c59e80766441418839f7a94c50c652c6a9d83b0f3ef1", - "https://deno.land/std@0.216.0/semver/difference.ts": "be4f01b7745406408a16b708185a48c1c652cc87e0244b12a5ca75c5585db668", - "https://deno.land/std@0.216.0/semver/equals.ts": "8b9b18260c9a55feee9d3f9250fba345be922380f2e8f8009e455c394ce5e81d", - "https://deno.land/std@0.216.0/semver/format.ts": "26d3a357ac5abd73dee0fe7dbbac6107fbdce0a844370c7b1bcb673c92e46bf6", - "https://deno.land/std@0.216.0/semver/format_range.ts": "ee96cc1f3002cfb62b821477f1a90374e6c021ec13045c34a0620021b1d862af", - "https://deno.land/std@0.216.0/semver/greater_or_equal.ts": "89c26f68070896944676eb9704cbb617febc6ed693720282741d6859c3d1fe80", - "https://deno.land/std@0.216.0/semver/greater_than.ts": "d8c4a227cd28ea80a1de9c80215d7f3f95786fe1b196f0cb5ec91d6567adad27", - "https://deno.land/std@0.216.0/semver/gtr.ts": "1101ecf6f427de9ba6348860f312c15b55f9301f97d2e34bd9e57957184574e9", - "https://deno.land/std@0.216.0/semver/increment.ts": "427a043be71d6481e45c1a3939b955e800924d70779cb297b872d9cbf9f0e46d", - "https://deno.land/std@0.216.0/semver/is_range.ts": "4cea4096436ea02555d38cc758effd978ca77d8ae63d8db15f2910b1ff04aec2", - "https://deno.land/std@0.216.0/semver/is_semver.ts": "57914027d6141e593eb04418aaabbfd6f4562a1c53c6c33a1743fa50ada8d849", - "https://deno.land/std@0.216.0/semver/less_or_equal.ts": "7dbf8190f37f3281048c30cf11e072a7af18685534ae88d295baa170b485bd90", - "https://deno.land/std@0.216.0/semver/less_than.ts": "b0c7902c54cecadcc7c1c80afc2f6a0f1bf0b3f53c8d2bfd11f01a3a414cccfe", - "https://deno.land/std@0.216.0/semver/ltr.ts": "4c147830444c9020eccc17cd8d4d9aced5b0f6cfb3c8b99fb9cdcca8fe90356f", - "https://deno.land/std@0.216.0/semver/max_satisfying.ts": "03e5182a7424c308ddbb410e4b927da0dabc4e07d4b5a72f7e9b26fb18a02152", - "https://deno.land/std@0.216.0/semver/min_satisfying.ts": "b6fadc9af17278289481c416e1eb135614f88063f4fc2b7b72b43eb3baa2f08f", - "https://deno.land/std@0.216.0/semver/mod.ts": "6fcb9531909763993c70e1278b898cc9dd1373d6f4cbcdc41be4fc629e9e2052", - "https://deno.land/std@0.216.0/semver/not_equals.ts": "17147a6f68b9d14f4643c1e2150378ccf6954710309f9618f75b411752a8e13d", - "https://deno.land/std@0.216.0/semver/parse.ts": "2ba215c9aa3c71be753570724cfad75cc81972f0026dc81836ea3d1986112066", - "https://deno.land/std@0.216.0/semver/parse_range.ts": "7ce841031e14af27c9abcc10878efe60135de59c935e311d671a91ffc48bbc46", - "https://deno.land/std@0.216.0/semver/range_intersects.ts": "28de545143652cffa447e859ad087d75e74c009762df0ebc2f03cca2eed9831d", - "https://deno.land/std@0.216.0/semver/range_max.ts": "b994695e885045518e1655a2c519d726003c7381b3e64be2090663378a6bfe20", - "https://deno.land/std@0.216.0/semver/range_min.ts": "269ace0521e055fe10ef8c3d342ddf2cf3eb9c53a8efb2eecac198085cc789c3", - "https://deno.land/std@0.216.0/semver/reverse_sort.ts": "98316b5c960cb0608df949d563328c7696d6b4f76ccea22a08974d27c1969893", - "https://deno.land/std@0.216.0/semver/test_range.ts": "6262307357a8b413dc34546c8401392ed2bc7a5e4ddd999195a56a0fe4fc1d4a", - "https://deno.land/std@0.216.0/semver/try_parse.ts": "a2639ec588c374331d5f655bfbdf8d5a2f2cc704c8b56ac94cd077944de150c7", - "https://deno.land/std@0.216.0/semver/try_parse_range.ts": "239e0d711c5745da8c4dcda3c0797b4b5a83bfe494a51d3a0f005472cdc7b016", - "https://deno.land/std@0.216.0/semver/types.ts": "8ed52c8cfc59e249a0dbab597d8c31bcf10405dcbe9714585055ea02252ae0d0", - "https://deno.land/std@0.216.0/testing/snapshot.ts": "35ca1c8e8bfb98d7b7e794f1b7be8d992483fcff572540e41396f22a5bddb944", - "https://deno.land/x/esbuild@v0.20.2/mod.js": "67c608ee283233f5d0faa322b887356857c547a8e6a00981f798b2cd38e02436", - "https://deno.land/x/esbuild@v0.20.2/wasm.js": "5a887c1e38ad1056af11c58d45b6084d33bd33a62afa480d805801739370eed0", - "https://deno.land/x/fresh@1.7.3/dev.ts": "720dd3a64b62b852db7b6ae471c246c5c605cf4a3091c4cbc802790f36d43e4c", - "https://deno.land/x/fresh@1.7.3/runtime.ts": "49f4f70c24d14c5d5e112a671ef0314e438e5cd83eacb4f75c6db2fbdc22b540", - "https://deno.land/x/fresh@1.7.3/server.ts": "d5817615a3ac822d422627f2cd6f850a31e11f7e73b328a79807f722e6519bac", - "https://deno.land/x/fresh@1.7.3/src/build/aot_snapshot.ts": "4ac6330e5325dd9411fa2a46e97bb289f910fde4be82dc349d3e2b4bb1a7c07d", - "https://deno.land/x/fresh@1.7.3/src/build/deps.ts": "03f73580f7e1ccf2027cb45357bfb82c73c3971876680ea8b44666bcbcd1a9f0", - "https://deno.land/x/fresh@1.7.3/src/build/esbuild.ts": "fdad9cc58f0ead0f954faad4a3c6b07da312acbe3306da742ba083ddb666d4b3", - "https://deno.land/x/fresh@1.7.3/src/build/mod.ts": "b9d1695a86746ac3a1c52f0e07e723faa2310d1dfd109b9a2eebab6727c4702b", - "https://deno.land/x/fresh@1.7.3/src/constants.ts": "4795d194b6c6b95f0e876c0a997fbaf57f94cfe253442c5819f95410870b79b3", - "https://deno.land/x/fresh@1.7.3/src/dev/build.ts": "9aaf84a781ee4d11d73ec549425f273fe8339812fdd8f726e1ec1ba61bdf0e9d", - "https://deno.land/x/fresh@1.7.3/src/dev/deps.ts": "93af624becfb2d8497e3d7ef0cf5ef48df61495dc5951b5f922c6a129a9fb75c", - "https://deno.land/x/fresh@1.7.3/src/dev/dev_command.ts": "3e3dcc80180faf8868d44d892ddfa8c5ad06033e4d94c0934302e157db1de63d", - "https://deno.land/x/fresh@1.7.3/src/dev/error.ts": "21a38d240c00279662e6adde41367f1da0ae7e2836d993f818ea94aabab53e7b", - "https://deno.land/x/fresh@1.7.3/src/dev/manifest.ts": "18911c84ee422799faf7dff2eeed75c7c9670ee26a01d0a33ab3b2aba1ba0381", - "https://deno.land/x/fresh@1.7.3/src/dev/mod.ts": "d44f3063d157bce53ba534d37b7ff8f262c379ab75229bc63d06c47c67b6b7e6", - "https://deno.land/x/fresh@1.7.3/src/dev/update_check.ts": "0b8e4659b49e3a951c684b7faf66be7428948c3e7d492d37397f9a485874515a", - "https://deno.land/x/fresh@1.7.3/src/runtime/Partial.tsx": "92e16fa7edf37dc8e254024a5410ea2c8986804a6ddf911af4d30209dff80a22", - "https://deno.land/x/fresh@1.7.3/src/runtime/active_url.ts": "c718797b11189c7e2c86569355d55056148907121e958e00f71c56593aecc329", - "https://deno.land/x/fresh@1.7.3/src/runtime/build_id.ts": "8376e70e42ce456dfa6932c638409d2ef1bca4833b4ceba0bf74510080a7f976", - "https://deno.land/x/fresh@1.7.3/src/runtime/csp.ts": "9ee900e9b0b786057b1009da5976298c202d1b86d1f1e4d2510bde5f06530ac9", - "https://deno.land/x/fresh@1.7.3/src/runtime/deserializer.ts": "1b83e75fa61c48b074ea121f33647d1ed15c68fa2f2a11b0a7f7a12cd38af627", - "https://deno.land/x/fresh@1.7.3/src/runtime/head.ts": "0f9932874497ab6e57ed1ba01d549e843523df4a5d36ef97460e7a43e3132fdc", - "https://deno.land/x/fresh@1.7.3/src/runtime/utils.ts": "4f40630c308e8ea7d53860687905caf1a2f2a46ad8692f24e905a8e996b584c3", - "https://deno.land/x/fresh@1.7.3/src/server/boot.ts": "3a574c4baa6120f6770f419af6d8d6d5ae32c8e1bdddf2bb14cb258ba18ac62f", - "https://deno.land/x/fresh@1.7.3/src/server/build_id.ts": "82d9cb985de6b1e38c3108e5a00667b16e80eedc145d73835d6b44349ebe6389", - "https://deno.land/x/fresh@1.7.3/src/server/code_frame.ts": "fac505f138fbd1bb260030122b87aeb2f5b5e54018e3066e105c669c686cc373", - "https://deno.land/x/fresh@1.7.3/src/server/compose.ts": "490aa1a7d540cc02bd4a184bea03eb2370aa34d93fe5a6ae1f31e2086eef4e76", - "https://deno.land/x/fresh@1.7.3/src/server/config.ts": "a5d0545d18c68d01770a4eb321d2fc85081ad7992ae63b1497f0b92ee122410a", - "https://deno.land/x/fresh@1.7.3/src/server/constants.ts": "e75a7f7b14185b6f85747613591348313200fe8e218cb473b1da9db941ee68d1", - "https://deno.land/x/fresh@1.7.3/src/server/context.ts": "1211da93ab80bf148892f0322df795e07212ea33ba361a2053e22cae3b5b54b3", - "https://deno.land/x/fresh@1.7.3/src/server/default_error_page.tsx": "094ad8d52d31f99172a606d0a0d8236604a1f9bb6d1f928d0d466d55b36dae70", - "https://deno.land/x/fresh@1.7.3/src/server/defines.ts": "f518ff10e499d4543bb9231f55026f26be2507eaccb072aafab93c8cc0bc3adc", - "https://deno.land/x/fresh@1.7.3/src/server/deps.ts": "efa2ddf6a21457839e42b6a69eca0c12a22a0bf3a6dd140b58abfe54e66328e8", - "https://deno.land/x/fresh@1.7.3/src/server/error_overlay.tsx": "e6ab4cef0ea812a1e1f32ee9116c61f64db8466d46e228acbb953215f4517d9c", - "https://deno.land/x/fresh@1.7.3/src/server/fs_extract.ts": "4dda675f03f0397310e4e6d4a57f3bf907db8a61a1a65423e67855daf5b9b36e", - "https://deno.land/x/fresh@1.7.3/src/server/htmlescape.ts": "834ac7d0caa9fc38dffd9b8613fb47aeecd4f22d5d70c51d4b20a310c085835c", - "https://deno.land/x/fresh@1.7.3/src/server/init_safe_deps.ts": "8c74d8708986d156126355b0935a1915069bfdc389ccabd3d2d72d1c9e04025c", - "https://deno.land/x/fresh@1.7.3/src/server/mod.ts": "6cee56e234f6bc19f62f3b6c0d287dc7b9632fcbfb8f56dde1d81423532d65c4", - "https://deno.land/x/fresh@1.7.3/src/server/render.ts": "b89387eb20c91969ace2de27b6c462f4e42c040d37b68440fe374e5cea9ea794", - "https://deno.land/x/fresh@1.7.3/src/server/rendering/fresh_tags.tsx": "5f1238e465d9ad94aebdf5e3701f2b9da3c944d8c5cc4dc8005ff1418b164989", - "https://deno.land/x/fresh@1.7.3/src/server/rendering/preact_hooks.ts": "db1a1ad7e4fbdac19b0758789ba7700531c214d531e1d03264b81a73beab05b5", - "https://deno.land/x/fresh@1.7.3/src/server/rendering/state.ts": "5e0c3a60964596cc28c1804545eae323cbc92eec9ce8cb0932d5168a6d1f33e9", - "https://deno.land/x/fresh@1.7.3/src/server/rendering/template.tsx": "bd1bc8edb054caac22043117f254927e8413e04cd1897009a2214ab374a1be19", - "https://deno.land/x/fresh@1.7.3/src/server/router.ts": "d051f24ff5578772703cb7af2bc4516da08c73c769839d7a1e9a3c82e8dfe0e8", - "https://deno.land/x/fresh@1.7.3/src/server/serializer.ts": "f0cffb863bbdbac6ed53fefe181e415d6aefc2101f2dc92a562b364088809e44", - "https://deno.land/x/fresh@1.7.3/src/server/tailwind_aot_error_page.tsx": "7265b66dc76a2e54b40774bbeb3cc7d4deb2eac537e08712e90e9c7b9399e53a", - "https://deno.land/x/fresh@1.7.3/src/server/types.ts": "cf4943a6d3e7df100aed20f243c0464cebb6af657749f742bd2c13e78c40dff2", - "https://deno.land/x/fresh@1.7.3/src/types.ts": "05169e3389979d8283de0ec1db3a765324ffd730b6af29ffe02752f341ae7d35", - "https://deno.land/x/fresh@1.7.3/versions.json": "953bb3cfa81ef8048733f47431f6683447aec211dd149a341a7d7244cf72d2cd", - "https://deno.land/x/ts_morph@21.0.1/common/mod.ts": "01985d2ee7da8d1caee318a9d07664774fbee4e31602bc2bb6bb62c3489555ed", - "https://deno.land/x/ts_morph@21.0.1/mod.ts": "adba9b82f24865d15d2c78ef6074b9a7457011719056c9928c800f130a617c93", - "https://esm.sh/*preact-render-to-string@6.3.1": "37f046906d7cc1ce35828a029a05a4eed6c8eefc4b646a301939757dfbf8ab18", - "https://esm.sh/@babel/helper-validator-identifier@7.22.20": "3b0b7d94b8ac689175ecb7e72183b07c6a771cc7680a7c9916091add73bd7f88" - }, "workspace": { "dependencies": [ "jsr:@david/dax@~0.43.2", diff --git a/packages/uri-template/README.md b/packages/uri-template/README.md new file mode 100644 index 00000000..806bf37c --- /dev/null +++ b/packages/uri-template/README.md @@ -0,0 +1,78 @@ + + +@fedify/uri-template: RFC 6570 URI Template implementation +=========================================================== + +[![JSR][JSR badge]][JSR] +[![npm][npm badge]][npm] + +This package provides [RFC 6570] fully compliant URI template expansion and +pattern matching library. Supports symmetric matching where +`expand(match(url))` and `match(expand(vars))` behave predictably. + +[JSR]: https://jsr.io/@fedify/uri-template +[JSR badge]: https://jsr.io/badges/@fedify/uri-template +[npm]: https://www.npmjs.com/package/@fedify/uri-template +[npm badge]: https://img.shields.io/npm/v/@fedify/uri-template?logo=npm +[RFC 6570]: https://datatracker.ietf.org/doc/html/rfc6570 + + +Features +-------- + + - **Full RFC 6570 Level 4 support**: Handles all operators and modifiers + (explode `*`, prefix `:n`) + - **Symmetric pattern matching**: + - `opaque`: byte-for-byte exact round-trips + - `cooked`: human-readable decoded values + - `lossless`: preserves both raw and decoded forms + - **Strict percent-encoding validation**: Prevents malformed sequences + (`%GZ`, etc.) + - **Deterministic expansion**: Correctly handles undefined/empty values per + RFC rules + + +Installation +------------ + +~~~~ sh +deno add jsr:@fedify/uri-template # Deno +npm add @fedify/uri-template # npm +pnpm add @fedify/uri-template # pnpm +yarn add @fedify/uri-template # Yarn +bun add @fedify/uri-template # Bun +~~~~ + + +Usage +----- + +~~~~ typescript +import { compile } from "@fedify/uri-template"; + +const tmpl = compile("/repos{/owner,repo}{?q,lang}"); + +// Expansion +const url = tmpl.expand({ owner: "foo", repo: "hello/world", q: "a b" }); +// => "/repos/foo/hello%2Fworld?q=a%20b" + +// Matching +const result = tmpl.match("/repos/foo/hello%2Fworld?q=a%20b", { + encoding: "cooked" +}); +// => { owner: "foo", repo: "hello/world", q: "a b" } +~~~~ + +**Matching options:** + + - `encoding`: `"opaque"` (default, preserves raw) | `"cooked"` (decoded) | + `"lossless"` (both) + - `strict`: `true` (default, strict) | `false` (lenient parsing) + + +Documentation +------------- + +For detailed implementation details, see [*specification.md*]. + +[*specification.md*]: ./docs/specification.md diff --git a/packages/uri-template/deno.json b/packages/uri-template/deno.json new file mode 100644 index 00000000..8b6f8c8d --- /dev/null +++ b/packages/uri-template/deno.json @@ -0,0 +1,15 @@ +{ + "name": "@fedify/uri-template", + "version": "2.0.0", + "license": "MIT", + "exports": { + ".": "./src/index.ts" + }, + "exclude": [ + "dist", + "node_modules" + ], + "tasks": { + "check": "deno fmt --check && deno lint && deno check" + } +} diff --git a/packages/uri-template/docs/specification.md b/packages/uri-template/docs/specification.md new file mode 100644 index 00000000..dfa8ff26 --- /dev/null +++ b/packages/uri-template/docs/specification.md @@ -0,0 +1,248 @@ +URI Template specification +========================== + +*This specification describes the `@fedify/uri-template` package implementation +as of version 0.1.0.* + + +Introduction +------------ + +This document explains how the `@fedify/uri-template` package implements +[RFC 6570] URI Templates and extends them with symmetric pattern matching. + +The package is built on three foundations. First, it parses a template string +into a small abstract syntax tree (AST) that represents RFC 6570 constructs. +Second, it *expands* variables into a URL using a single, deterministic encoder +that follows operator-specific rules. Third, it *matches* existing URLs back +to variables using the same AST and rule table, adding explicit encoding modes +so callers can choose byte-preserving or human-readable behavior. This +unification is what makes round-trips predictable with no ad-hoc heuristics. + +[RFC 6570]: https://datatracker.ietf.org/doc/html/rfc6570 + + +RFC 6570 elements in practice +------------------------------ + +> RFC 6570 divides a template into **literals** and **expressions**. + +A **literal** is any substring outside curly braces. Literals are copied +directly during expansion and must match exactly during pattern matching. +If a literal contains `%` sequences, those sequences are not decoded—literals +are treated as already-encoded text. + +For example, in the template `/users/{id}/profile`, the strings `/users/`, `/`, +and `/profile` are literals. During expansion, these parts remain unchanged, +so if `id` expands to `123`, the result would be `/users/123/profile`. If the +literal contained encoded characters like `/users%2F{id}`, the `%2F` sequence +would remain as-is rather than being decoded to `/`. + +An *expression* is enclosed in `{...}` and contains an optional *operator* +followed by a comma-separated list of variable specifications (varspecs)[^1]: + +~~~~ +{ operator? var1, var2, var3 } +~~~~ + +Each varspec may include two modifiers: + + - `:n` (prefix): Only the first `n` characters of the variable are used, and + the truncation happens before any percent-encoding. + - `*` (explode): Lists and maps expand into multiple items rather than + a single comma-joined value. + +RFC 6570 defines eight operators, each with distinct behavior: + + - *Simple* (`{var}`): Outputs values comma-separated. Reserved characters + are encoded. + - *Reserved* (`{+var}`): Like simple, but reserved characters are allowed to + pass through unencoded. + - *Fragment* (`{#var}`): Like reserved, but the full expression is prefixed + with `#`. The operator allows many reserved characters to pass, but never + a literal `#`, which would start a new fragment. + - *Label* (`{.var}`): Each value is prefixed with a dot. Even an *empty* + value still emits the dot (e.g., `"X{.y}"` with `y=""` yields `"X."`)[^2]. + - *Path segments* (`{/var}`): Each value is prefixed with `/`. + - *Matrix parameters* (`{;x,y}`): Each variable is prefixed with `;` and is + *named*. + - Empty becomes `;x` + - Undefined is omitted entirely + - *Query* (`{?x,y}`): First character `?`, then name/value pairs joined with + `&`. + - An empty value becomes `x=` + - Undefined is omitted + - *Query continuation* (`{&x,y}`): Like query but begins with `&`, intended + to append to existing query strings. + +> [!NOTE] +> The distinction between "undefined" and "empty" is critical and depends on +> the specific operator being used. "Undefined" means the variable should be +> *omitted entirely* from the output. In contrast, "empty" means the operator +> should *emit something* according to its rules: `nameOnly` format for matrix +> parameters (`;x`), `empty` format for queries (`x=`), or omission for most +> other operators—except for labels, which still print the dot separator. + + +Parsing strategy +----------------- + +Parsing is a single forward scan that alternates between collecting literals and +parsing expressions. We avoid broad regex for resilient parsing and, more +importantly, it is less error-prone when you need exact source positions and +behavior around edge cases. + + 1. *Scan for `{`*: Everything preceding it forms a Literal node. + + 2. *Read an expression*: The next character may be one of `+#./;?&`. + If present, this character serves as the operator; otherwise, default to the + "simple" operator. + + 3. *Parse a varspec list*: For each variable specification, read the + following components: + + - The variable name (must be non-empty) + - An optional `:n` prefix modifier, where `n` is a non-negative integer + - An optional `*` explode flag + - Either a comma (indicating additional varspecs follow) or a closing + brace + + 4. *Require `}`*: If the input terminates before encountering the closing + brace, this constitutes a parse error—templates must be properly balanced. + +The result is a small AST: a sequence of `Literal` and `Expression` nodes. +Every later phase—expansion and matching—walks this same AST and consults +a single "operator spec" table. This is the design fulcrum for symmetry: both +directions share exactly the same structure and tables. + + +Expansion—from variables to URL +-------------------------------- + +Expansion takes the AST and a dictionary of variables. For literals, it copies +text unchanged. For expressions, it computes a sequence of *pieces* and then +emits them with the operator's rules: + +*Encoding is idempotent* +: Existing `%XX` sequences remain intact, while characters requiring encoding + are converted to UTF-8 bytes (`%HH`). + +*Truncation (prefix `:n`) occurs before encoding* +: Truncating after encoding risks splitting a `%HH` triplet; RFC 6570 requires + truncation on the pre-encoded string. + +*Explode* +: Transforms lists or maps into multiple items instead of a single joined + value. + +*Join* +: Pieces using the operator's separator and prepend the operator's *first + character* (such as `#`, `.`, `/`, `;`, `?`, `&`) once, if defined. + +*Empty/undefined handling*: The RFC specifies precise rules for these edge +cases: + + - *Matrix* (`;`): Empty values yield `;x`, undefined values are omitted + - *Query* (`?`/`&`): Empty values yield `x=`, undefined values are omitted + - *Label* (`.`): Empty values still emit the dot separator (a commonly + overlooked edge case) + +These rules ensure that expansion from structured data produces deterministic +and stable results, eliminating ambiguity about when to include separators or +variable names. + + +Pattern matching +----------------- + +Matching reads a URL string and attempts to recover the variables that would +produce that URL when expanded with the same template. + +*Core Approach*: The fundamental concept is to reverse the expansion process +systematically rather than rely on heuristics. We traverse the same AST used +for expansion. For each literal node, we require it to appear exactly at the +current position. For expressions, we: + + - *Consume operator prefix*: If the operator defines a first character + (`?`, `;`, `#`, `/`, `.`), we require its presence and consume it. + + - *Greedy capture*: Until reaching the next concrete boundary: + + 1. The subsequent literal in the AST + 2. The operator's item separator when matching multiple variables within + the same expression + + - *Preserve encoding integrity*: When splitting captured text by separators, + we treat percent triplets as indivisible atoms, never splitting within `%HH` + sequences to avoid corrupting encoded bytes. + + - *Parse named operators*: For operators like `;`, `?`, and `&`, we parse + `name=value` pairs but store **only the value** for each variable, mirroring + how expansion generates names from operators rather than variable content. + + - *Infer exploded structure*: For exploded named lists (e.g., `;tags*`), we + determine structure based on patterns: + + - If every segment follows `tags=...` format, we return an array of values + - Otherwise, we interpret as a key-value mapping (`?a=1&b=2` → + `{ a: "1", b: "2" }`) + +### Encoding modes + +Encoding modes control the form of captured values: + +*Opaque* +: Preserves raw bytes (percent sequences) exactly. If you match a URL with + `"a%2Fb"`, you get `"a%2Fb"`. This enables byte-for-byte round-trips. + +*Cooked* +: Decodes a valid `%HH` sequence exactly once, returning human-readable values + such as `"a/b"`. This is convenient for application logic and enables + semantic round-trips. + +*Lossless* +: Returns both views `{ raw, decoded }`, allowing callers to decide per + variable whether to preserve original bytes or use decoded text. + +These options are explicit rather than implicit, providing flexibility while +maintaining correctness. + + +Round-trip guarantees +--------------------- + +While RFC 6570 briefly mentions that "some URI Templates can be used in reverse +for the purpose of variable matching"[^3], it provides no formal specification +or guarantees for this behavior. Symmetry is often promised by implementations +but rarely defined precisely. + +This package provides explicit round-trip guarantees as a core feature: + +### Matching then expanding (byte symmetry) + +Under `opaque` mode, for any URL that matches the template, re-expanding the +matched variables produces *the exact same bytes*. Formally: + +~~~~ typescript +expand(match(url, { encoding: "opaque" }).vars) === url +~~~~ + +This is essential for reverse routing, ensuring that URL patterns can be +reliably inverted. + +### Expanding then matching (semantic symmetry) + +Under `cooked` mode, for any valid variable dictionary, expanding and then +matching recovers semantically equivalent values: + +~~~~ typescript +const matched = match(expand(vars), { encoding: "cooked" }); +// matched.vars is semantically equivalent to vars +~~~~ + +This guarantees that the meaning of variables is preserved through the +round-trip, even if the exact byte representation differs due to normalization. + +[^1]: +[^2]: +[^3]: (page 10) diff --git a/packages/uri-template/package.json b/packages/uri-template/package.json new file mode 100644 index 00000000..78b5ddb1 --- /dev/null +++ b/packages/uri-template/package.json @@ -0,0 +1,61 @@ +{ + "name": "@fedify/uri-template", + "version": "2.0.0", + "description": "URI Template (RFC 6570) utilities for Fedify", + "keywords": [ + "Fedify", + "URI", + "template", + "RFC6570" + ], + "author": { + "name": "Lee ByeongJun", + "email": "lbj199874@gmail.com" + }, + "homepage": "https://fedify.dev/", + "repository": { + "type": "git", + "url": "git+https://github.com/fedify-dev/fedify.git", + "directory": "packages/uri-template" + }, + "license": "MIT", + "bugs": { + "url": "https://github.com/fedify-dev/fedify/issues" + }, + "funding": [ + "https://opencollective.com/fedify", + "https://github.com/sponsors/dahlia" + ], + "type": "module", + "module": "./dist/index.js", + "main": "./dist/index.cjs", + "types": "./dist/index.d.ts", + "exports": { + ".": { + "types": { + "require": "./dist/index.d.cts", + "default": "./dist/index.d.ts" + }, + "default": { + "require": "./dist/index.cjs", + "default": "./dist/index.js" + } + }, + "./package.json": "./package.json" + }, + "files": [ + "dist/", + "package.json" + ], + "devDependencies": { + "@types/node": "catalog:", + "tsdown": "catalog:", + "typescript": "catalog:" + }, + "scripts": { + "build": "deno task codegen && tsdown", + "prepack": "pnpm run build", + "prepublish": "pnpm run build", + "test": "tsdown && node --experimental-transform-types --test" + } +} diff --git a/packages/uri-template/src/ast.ts b/packages/uri-template/src/ast.ts new file mode 100644 index 00000000..e88026a0 --- /dev/null +++ b/packages/uri-template/src/ast.ts @@ -0,0 +1,49 @@ +/** + * RFC 6570 operator set. Keep in sync with {@link OperatorSpec} in spec.ts. + * The operator controls separators, first-char prefix, and reserved char handling. + */ +export type Operator = + | "" // simple + | "+" + | "#" + | "." + | "/" + | ";" + | "?" + | "&"; + +/** + * A variable specification inside an expression + * - `explode` (`*`) and `prefix` (`:n`) modifies are Level 4 features. + * - Parser guarantees: if `prefix` is present, it is a positive integer. + */ +export interface VarSpec { + name: string; + explode: boolean; + prefix?: number; // :n +} + +export type Node = Literal | Expression; + +export interface Literal { + kind: "literal"; + value: string; // as-is literal (template text) + start?: number; // optional raw slice for debugging + end?: number; +} + +export interface Expression { + kind: "expression"; + op: Operator; + vars: VarSpec[]; + start?: number; + end?: number; +} + +/** + * Template AST root. Expansion and matching both consume this structure. + * @note Literals are kept as-is to avoid re-encoding surprises. + */ +export interface TemplateAst { + nodes: Node[]; +} diff --git a/packages/uri-template/src/compile.ts b/packages/uri-template/src/compile.ts new file mode 100644 index 00000000..19f96c60 --- /dev/null +++ b/packages/uri-template/src/compile.ts @@ -0,0 +1,91 @@ +import type { TemplateAst } from "./ast.ts"; +import { expand, type Vars } from "./expand.ts"; +import { type EncodingPolicy, match, type MatchOptions } from "./match.ts"; +import { parse } from "./parser.ts"; + +/** + * Options that control how a compiled template behaves during matching. + * + * ### encoding + * Determines how percent-encoded sequences are handled. + * + * ### strict + * If true (default), malformed percent triplets (e.g. "%GZ" or lone "%") + * cause matching to fail immediately. + * Disabling strict mode may allow more lenient parsing but can lead to ambiguity. + */ +export interface CompileOptions { + encoding?: EncodingPolicy; + strict?: boolean; +} + +/** + * A compiled URI template that can efficiently expand and match URLs. + * + * @typeParam V - The type of variables expected by this template + * + * @example + * ```typescript + * const t = compile("{+path}/here"); + * const url = t.expand({ path: "/foo/bar" }); // "/foo/bar/here" + * const match = t.match("/foo/bar/here"); // { vars: { path: "/foo/bar" } } + * ``` + * + * @example + * ```typescript + * const t = compile("/repos{/owner,repo}{?q,lang}"); + * const url = t.expand({ owner: "alice", repo: "hello/world", q: "a b", lang: "en" }); + * // "/repos/alice/hello%2Fworld?q=a%20b&lang=en" + * ``` + */ +export interface CompiledTemplate> { + /** + * Get the parsed AST of this template. + * Useful for diagnostics and introspection. + * + * @returns The parsed template AST + */ + ast(): TemplateAst; + + /** + * Expand the template with the given variables according to RFC 6570. + * + * @param vars - Variables to substitute into the template + * @returns The expanded URL string + */ + expand(vars: V & Vars): string; + + /** + * Match a URL against this template and extract variables. + * + * @param url - The URL to match against the template + * @param opts - Optional matching options + * @returns An object with extracted variables if matched, null otherwise + */ + match(url: string, opts?: MatchOptions): null | { vars: V }; +} + +/** + * Compile a template string once. + * Returns a handle with: + * - `ast()` -> the parsed AST (for diagnostics/introspection) + * - `expand()` -> RFC 6570 expansion (L1–L4) + * - `match()` -> symmetric pattern matching + * + * Rationale: + * Compilation isolates parsing cost and allows future VM/bytecode backends + * to optimize hot routes without changing the API. + */ +export function compile>( + template: string, +): CompiledTemplate { + const ast = parse(template); + return { + ast: () => ast, + expand: (vars: V & Vars) => expand(ast, vars), + match: (url: string, mo?: MatchOptions) => { + const result = match(ast, url, mo); + return result ? { vars: result.vars as V } : null; + }, + }; +} diff --git a/packages/uri-template/src/error.ts b/packages/uri-template/src/error.ts new file mode 100644 index 00000000..a31521d0 --- /dev/null +++ b/packages/uri-template/src/error.ts @@ -0,0 +1,35 @@ +/** + * Error thrown when parsing an invalid RFC 6570 URI template. + */ +export class ParseError extends Error { + /** + * The error name, always "RFC6570ParseError". + */ + override name: "RFC6570ParseError" = "RFC6570ParseError" as const; + + /** + * The index in the template string where the error occurred. + */ + index: number; + + /** + * Create a new ParseError. + * + * @param message - The error message + * @param index - The index in the template string where the error occurred + */ + constructor(message: string, index: number) { + super(message); + this.index = index; + // Maintains proper prototype chain for instanceof checks + Object.setPrototypeOf(this, ParseError.prototype); + } +} + +/** + * Create a ParseError with a formatted message. + * @internal + */ +export function err(_src: string, index: number, msg: string): ParseError { + return new ParseError(`${msg} at ${index}`, index); +} diff --git a/packages/uri-template/src/expand.ts b/packages/uri-template/src/expand.ts new file mode 100644 index 00000000..471a2a4e --- /dev/null +++ b/packages/uri-template/src/expand.ts @@ -0,0 +1,189 @@ +import type { Expression, Operator, TemplateAst, VarSpec } from "./ast.ts"; +import { encodeComponentIdempotent, OP } from "./spec.ts"; + +/** + * A scalar value that can be used in template expansion. + */ +type Scalar = string | number | boolean; + +/** + * A list of scalar values that can be used in template expansion. + */ +type List = (Scalar | undefined)[]; + +/** + * A map-like object with scalar values. + */ +type MapLike = Record; + +/** + * Valid value types for template variables. + */ +type VarsValue = Scalar | List | MapLike | undefined; + +/** + * A collection of variables for template expansion. + * Maps variable names to their values. + * + * @example + * ```typescript + * const vars: Vars = { + * var: "value", + * list: ["red", "green", "blue"], + * keys: { semi: ";", dot: ".", comma: "," } + * }; + * ``` + */ +export type Vars = Record; + +function emitNamed( + spec: typeof OP[Operator], + name: string, + raw: string, +): string { + return spec.named + ? (raw === "" && spec.ifEmpty === "empty" + ? `${name}${spec.kvSep}` + : `${name}${spec.kvSep}${raw}`) + : raw; +} + +function expandVar( + op: Expression["op"], + v: VarSpec, + value: VarsValue, +): string[] { + const spec = OP[op]; + const enc = (str: string) => + encodeComponentIdempotent(str, spec.allowReserved, spec.reservedSet); + + // undefined/null + if (value === undefined || value === null) { + // 1. For operators with undefined values: must be completely omitted (not even the name) + // 2. For operators with empty string values: should output only the name without = (nameOnly behavior) + return []; + } + + // Array + if (Array.isArray(value)) { + const items = value.filter((x) => x !== undefined).map((x) => + enc(String(x)) + ); + if (items.length === 0) { + if (spec.first === ".") return [""]; // empty label still emits the dot + if (spec.named && spec.ifEmpty === "nameOnly") return [v.name]; + if (spec.named && spec.ifEmpty === "empty") { + return [`${v.name}${spec.kvSep}`]; + } + return []; + } + if (v.explode) return items.map((it) => emitNamed(spec, v.name, it)); + return [emitNamed(spec, v.name, items.join(","))]; + } + + // Map + if (typeof value === "object" && value && !Array.isArray(value)) { + const entries = Object.entries(value as MapLike).filter(([, vv]) => + vv !== undefined + ); + if (entries.length === 0) { + if (spec.named && spec.ifEmpty === "nameOnly") return [v.name]; + if (spec.named && spec.ifEmpty === "empty") { + return [`${v.name}${spec.kvSep}`]; + } + return []; + } + if (v.explode) { + return entries.map(([k, vv]) => + `${enc(k)}${spec.kvSep}${enc(String(vv as Scalar))}` + ); + } + const joined = entries.map(([k, vv]) => + `${enc(k)},${enc(String(vv as Scalar))}` + ).join(","); + return [emitNamed(spec, v.name, joined)]; + } + + // Scalar + let s = String(value as Scalar); + // Prefix `:n` applies BEFORE encoding; then we encode the substring. + // Do not slice encoded output. + if (v.prefix !== undefined) s = s.slice(0, v.prefix); + const e = enc(s); + + if (e.length === 0) { + // Label '.' must still print the dot even if the value is empty. + // We return [""] so caller pints `first="."` + empty piece -> "." + if (spec.first === ".") return [""]; + if (spec.named && spec.ifEmpty === "nameOnly") return [v.name]; + if (spec.named && spec.ifEmpty === "empty") { + return [`${v.name}${spec.kvSep}`]; + } + return []; + } + return [emitNamed(spec, v.name, e)]; +} + +/** + * Expand a parsed template with variables according to RFC 6570 (Level 1-4). + * + * @param ast - The parsed template AST to expand + * @param vars - Variables to substitute into the template + * @returns The expanded URL string + * + * @remarks + * - Idempotent percent encoding (existing `%XX` kept) + * - Operator-specific empty/undefined rules: + * - ";" empty -> `nameOnly` (";x") + * - "?" "&" empty -> "key=" ("?x=") + * - `undefined` -> `omit` (all operators) + * - Label "." emits the dot even if empty ("X{.y}" with y="" -> "X.") + * + * @example + * ```typescript + * import { parse } from "./parser.ts"; + * import { expand } from "./expand.ts"; + * + * const ast = parse("{+x,hello,y}"); + * const url = expand(ast, { x: "1024", hello: "Hello World!", y: "768" }); + * // Returns: "1024,Hello%20World!,768" + * ``` + * + * @example + * ```typescript + * import { parse } from "./parser.ts"; + * import { expand } from "./expand.ts"; + * + * const ast = parse("{+path}/here"); + * const url = expand(ast, { path: "/foo/bar" }); + * // Returns: "/foo/bar/here" + * ``` + */ +export function expand(ast: TemplateAst, vars: Vars): string { + let out = ""; + for (const node of ast.nodes) { + // no need to encode literals, append as-is + if (node.kind === "literal") { + out += node.value; + } else { + const spec = OP[node.op]; + const pieces: string[] = []; + + for (const v of node.vars) { + // Expand a single `VarSpec` into 0..n pieces per operator rules. + // For explode lists/maps we may produce multiple times. + pieces.push(...expandVar(node.op, v, vars[v.name])); + } + + // If this expr produced no output, nothing to prepend + if (pieces.length === 0) continue; + + // Prepend operator 1st char (e.g. "#", ".", "/", ";", "?", "&") + if (spec.first) out += spec.first; + + // Join with operator item separator (",", ".", "/", "&", ";") + out += pieces.join(spec.itemSep); + } + } + return out; +} diff --git a/packages/uri-template/src/index.ts b/packages/uri-template/src/index.ts new file mode 100644 index 00000000..a98c0f53 --- /dev/null +++ b/packages/uri-template/src/index.ts @@ -0,0 +1,5 @@ +export * from "./ast.ts"; +export * from "./compile.ts"; +export * from "./expand.ts"; +export * from "./match.ts"; +export * from "./parser.ts"; diff --git a/packages/uri-template/src/match.spec.ts b/packages/uri-template/src/match.spec.ts new file mode 100644 index 00000000..453b57a7 --- /dev/null +++ b/packages/uri-template/src/match.spec.ts @@ -0,0 +1,138 @@ +// deno-lint-ignore-file no-explicit-any +import { + deepStrictEqual as assertEquals, + notDeepStrictEqual as assertNotEquals, +} from "node:assert/strict"; +import { describe, test } from "node:test"; +import { compile } from "./compile.ts"; + +describe("encoding modes", () => { + test("opaque vs cooked vs lossless — path segment", () => { + const t = compile("/files{/path}"); + + // URL contains an encoded slash (%2F) + const url = "/files/a%2Fb"; + + const mOpaque = t.match(url, { encoding: "opaque" }); + const mCooked = t.match(url, { encoding: "cooked" }); + const mLoss = t.match(url, { encoding: "lossless" }); + + if (!mOpaque || !mCooked || !mLoss) throw new Error("match failed"); + + // opaque: raw percent string preserved + assertEquals(mOpaque.vars.path, "a%2Fb"); + + // cooked: one decoding pass => human-readable "a/b" + assertEquals(mCooked.vars.path, "a/b"); + + // lossless: both forms available + assertEquals((mLoss.vars as any).path.raw, "a%2Fb"); + assertEquals((mLoss.vars as any).path.decoded, "a/b"); + }); + + test("opaque vs cooked vs lossless — query (named, non-explode)", () => { + const t = compile("/s{?q}"); + const url = "/s?q=a%20b"; + + const mo = t.match(url, { encoding: "opaque" }); + const mc = t.match(url, { encoding: "cooked" }); + const ml = t.match(url, { encoding: "lossless" }); + + if (!mo || !mc || !ml) throw new Error("match failed"); + + assertEquals(mo.vars.q, "a%20b"); // opaque + assertEquals(mc.vars.q, "a b"); // cooked + assertEquals((ml.vars as any).q.raw, "a%20b"); // lossless + assertEquals((ml.vars as any).q.decoded, "a b"); + }); + + test("opaque vs cooked — fragment (# operator)", () => { + // Note: we match against a fragment-bearing URL literal. + const t = compile("{#frag}"); + const url = "#a%2Fb%23c"; // "a/b#c" after one decode + + const mo = t.match(url, { encoding: "opaque" }); + const mc = t.match(url, { encoding: "cooked" }); + + if (!mo || !mc) throw new Error("match failed"); + + assertEquals(mo.vars.frag, "a%2Fb%23c"); // raw held + assertEquals(mc.vars.frag, "a/b#c"); // decoded once + }); +}); + +describe("round-trip behavior", () => { + test("round-trip: opaque always byte-equal; cooked not guaranteed on non-canonical URLs", () => { + // OPAQUE: canonical URL -> byte-for-byte equality guaranteed + { + const t1 = compile("/repos{/owner,repo}{?q,lang}"); + const url1 = "/repos/alice/hello%2Fworld?q=a%20b&lang=en"; + const m1 = t1.match(url1, { encoding: "opaque" }); + if (!m1) throw new Error("opaque match failed"); + assertEquals(t1.expand(m1.vars as any), url1); + } + // COOKED: use a non-canonical URL (double-encoded %252F) to force inequality + { + const t2 = compile("/files{/path}"); + const url2 = "/files/a%252Fb"; // "%252F" decodes once to "%2F" + const m2 = t2.match(url2, { encoding: "cooked" }); + if (!m2) throw new Error("cooked match failed"); + // Re-expansion encodes deterministically to "%2F", so bytes differ + assertNotEquals(t2.expand(m2.vars as any), url2); + } + }); + + test("semantic round-trip holds for cooked", () => { + const t = compile("/u{/id}{?q}"); + + // human readable vars + const vars = { id: "a/b", q: "x y" }; + const url = t.expand(vars); + + const mc = t.match(url, { encoding: "cooked" }); + if (!mc) throw new Error("cooked match failed"); + + assertEquals(mc.vars, vars); + }); +}); + +describe("lossless mode", () => { + test("lossless returns both forms and can re-expand either (non-canonical source)", () => { + const t = compile("/doc{/path}{?q}"); + // both path and query contain double-encoded bytes + const nonCanonicalUrl = "/doc/a%252Fb?q=x%2520y"; + const ml = t.match(nonCanonicalUrl, { encoding: "lossless" }); + if (!ml) throw new Error("lossless match failed"); + const { path, q } = ml.vars as any; + + // One decode step + assertEquals(path.raw, "a%252Fb"); + assertEquals(path.decoded, "a%2Fb"); + assertEquals(q.raw, "x%2520y"); + assertEquals(q.decoded, "x%20y"); + const urlFromRaw = t.expand({ path: path.raw, q: q.raw }); + const urlFromDec = t.expand({ path: path.decoded, q: q.decoded }); + assertEquals(urlFromRaw, nonCanonicalUrl); // raw view preserves original bytes + assertNotEquals(urlFromDec, nonCanonicalUrl); // decoded view canonicalizes bytes + }); +}); + +describe("strict mode", () => { + test("strict mode: bad percent triplet fails; non-strict tolerates", () => { + const t = compile("/x{/id}"); + + const malformedPctTriplet = "/x/%GZ"; + + // default strict=true -> should fail + const mStrict = t.match(malformedPctTriplet, { encoding: "opaque" }); + assertEquals(mStrict, null); + + // strict=false -> tolerate (captures raw string including '%GZ') + const mLenient = t.match(malformedPctTriplet, { + encoding: "opaque", + strict: false, + }); + if (!mLenient) throw new Error("lenient match unexpectedly failed"); + assertEquals(mLenient.vars.id, "%GZ"); + }); +}); diff --git a/packages/uri-template/src/match.ts b/packages/uri-template/src/match.ts new file mode 100644 index 00000000..f2d59823 --- /dev/null +++ b/packages/uri-template/src/match.ts @@ -0,0 +1,328 @@ +import type { TemplateAst, VarSpec } from "./ast.ts"; +import { + looksLikePctTriplet, + OP, + type OperatorSpec, + strictPercentDecode, +} from "./spec.ts"; + +/** + * Encoding policy for variable values when matching URLs. + * + * Three modes are supported: + * - `"opaque"` (default): + * - Treats `%XX` as opaque atoms. + * - No decoding is applied; raw bytes are preserved. + * - Guarantees byte-for-byte symmetry: + * `expand(match(url)) === url`. + * - Background: added to solve `uri-template-router#11` where + * sequences like "%30#" lost information. + * + * - `"cooked"`: + * - Decodes valid `%XX` sequences exactly once. + * - More convenient for application logic where you want + * human-readable values (`"a%20b"` → `"a b"`). + * - Guarantees semantic symmetry: + * `match(expand(vars)) === vars`. + * - Note: Name chosen to contrast with `"opaque"`: think of + * “cooked strings” (escaped string literals) in programming languages. + * + * - `"lossless"`: + * - Returns both raw and decoded forms: + * `{ raw: "a%2Fb", decoded: "a/b" }`. + * - Useful if you need to display the original URL while + * still working with decoded values. + */ +export type EncodingPolicy = "opaque" | "cooked" | "lossless"; + +/** + * Options for matching URLs against templates. + * + * @remarks + * These options control how percent-encoded sequences are handled during matching. + */ +export interface MatchOptions { + /** + * The encoding policy for variable values (default: "opaque"). + */ + encoding?: EncodingPolicy; + + /** + * If true (default), malformed percent triplets cause matching to fail. + * If false, allows more lenient parsing but may lead to ambiguity. + */ + strict?: boolean; +} + +type VarsOut = Record; + +function decodeAccordingToPolicy(s: string, policy: EncodingPolicy): unknown { + if (policy === "opaque") return s; + if (policy === "cooked") return strictPercentDecode(s); + return { raw: s, decoded: strictPercentDecode(s) }; +} + +/** + * consume a percent triplet or a single char; used to advance safely + * + * Percent triplet handling policies: + * - When advancing, treat "%XX" as a single atom to avoid slicing inside a byte. + * - In strict mode, a bare '%' or bad hex after '%' fails the match. + */ +function advanceOne(url: string, i: number): number { + if (i < url.length && url[i] === "%" && looksLikePctTriplet(url, i)) { + return i + 3; + } + return i + 1; +} + +/** + * Match a URL string against a compiled template and extract variables. + * + * @param ast - The parsed template AST to match against + * @param url - The URL string to match + * @param opts - Optional matching options + * @returns An object with extracted variables if matched, null otherwise + * + * Encoding policies: + * - "opaque" -> return raw percent-encoded slices (byte-for-byte round-trip) + * - "cooked" -> decode %XX exactly once + * - "lossless"-> { raw, decoded } pair + * + * Strictness: + * - strict=true rejects bad percent triplets (e.g. "%GZ"), preventing + * ambiguous or lossy normalization early. + * + * @example + * ```typescript + * import { parse } from "./parser.ts"; + * import { match } from "./match.ts"; + * + * const ast = parse("/repos{/owner,repo}{?q,lang}"); + * const result = match(ast, "/repos/alice/hello%2Fworld?q=a%20b&lang=en", { encoding: "opaque" }); + * // Returns: { vars: { owner: "alice", repo: "hello%2Fworld", q: "a%20b", lang: "en" } } + * ``` + * + * @example + * ```typescript + * import { parse } from "./parser.ts"; + * import { match } from "./match.ts"; + * + * const ast = parse("/files{/path}"); + * const result = match(ast, "/files/a%2Fb", { encoding: "cooked" }); + * // Returns: { vars: { path: "a/b" } } + * ``` + */ +export function match( + ast: TemplateAst, + url: string, + opts?: MatchOptions, +): null | { vars: VarsOut } { + const policy: EncodingPolicy = opts?.encoding ?? "opaque"; + const strict = opts?.strict ?? true; + + let i = 0; + const varsOut: Record = {}; + + // Comsume a literal exactly + const readLiteral = (lit: string): boolean => { + if (url.slice(i, i + lit.length) !== lit) return false; + i += lit.length; + return true; + }; + + // URL: /users/foo/hello%2Fworld?q=a%20b&lang=en + // Tmpl: /users{/owner,repo}{?q,lang} + // Phases: LIT----^ EXPR(owner,repo) EXPR(q,lang) + // i-> after literal + // [capture until itemSep or next literal/operator.first] + const nextIs = (s: string) => url.slice(i, i + s.length) === s; + + for (const node of ast.nodes) { + if (node.kind === "literal") { + if (!readLiteral(node.value)) return null; + continue; + } + const spec = OP[node.op]; + if (spec.first) { + // Operators starting with a distinct char must appear in URL + if (!readLiteral(spec.first)) return null; + } + + // Greedy capture for each var until separator or upcoming literal. + // We respect %XX boundaries to avoid splitting in the middle of bytes. + // + // For each variable (or entry) separated by itemSep, we capture greedily until: + // - next literal (if any) starts + // - or next separator occurs + const takeUntil = (stopPred: (j: number) => boolean): string => { + const start = i; + while (i < url.length && !stopPred(i)) { + // advance by pct or char, but if strict and raw '%' without triplet => fail + if (strict && url[i] === "%" && !looksLikePctTriplet(url, i)) return ""; + i = advanceOne(url, i); + } + return url.slice(start, i); + }; + + const emitScalar = (v: VarSpec, raw: string) => { + varsOut[v.name] = decodeAccordingToPolicy(raw, policy); + }; + + const splitItems = (raw: string): string[] => { + if (raw.length === 0) return [""]; + const sep = spec.itemSep; + // split on itemSep but keep %XX intact + const out: string[] = []; + let start = 0; + for (let j = 0; j <= raw.length;) { + if (j === raw.length || raw.slice(j, j + sep.length) === sep) { + out.push(raw.slice(start, j)); + j += sep.length; + start = j; + continue; + } + j = advanceOne(raw, j); + } + return out; + }; + + const captureOneVar = ( + v: VarSpec, + last: boolean, + nextLiteral: string | null, + ): boolean => { + const stopPred = (j: number) => { + if ( + nextLiteral && url.slice(j, j + nextLiteral.length) === nextLiteral + ) return true; + // otherwise stop on itemSep if not last + if ( + !last && spec.itemSep && + url.slice(j, j + spec.itemSep.length) === spec.itemSep + ) return true; + return false; + }; + const raw = takeUntil(stopPred); + if ( + raw === "" && + (strict && i < url.length && url[i] !== spec.itemSep && + nextLiteral === null) + ) { + // invalid % or nothing captured when something expected + return false; + } + // explode / map-like detection: we only parse structurally if v.explode OR there is '=' present with named + if (v.explode) { + const parts = splitItems(raw); + if (spec.named) { + varsOut[v.name] = parseExplodeNamedParts(v, parts, spec, policy); + } else { + // non-named explode: list/map compact form to be enhanced later + varsOut[v.name] = parts.map((p) => + decodeAccordingToPolicy(p, policy) + ); + } + } else { + if (spec.named) { + const [ok, val] = captureNamedNonExplode(v, raw, spec, policy); + if (!ok) return false; + varsOut[v.name] = val; + } else { + emitScalar(v, raw); + } + } + // consume itemSep if present and not last + if (!last && spec.itemSep && nextIs(spec.itemSep)) { + i += spec.itemSep.length; + } + return true; + }; + + // Named operators ( ; ? & ): + // - Non-explode: "name[=value]" -> store VALUE only (not "name=") + // - Explode: ";x=a;x=b" or "?k=v&..." -> list or map + // Decision rule for explode named parts: + // if every piece starts with "varName=" => list + // else => map (k=v) + for (let idx = 0; idx < node.vars.length; idx++) { + const v = node.vars[idx]; + const last = idx === node.vars.length - 1; + + // Determine the next literal after this expression to bound greedy capture + let nextLiteral: string | null = null; + // look ahead in AST: find the next literal node + const nodeIndex = ast.nodes.indexOf(node); + for (let k = nodeIndex + 1; k < ast.nodes.length; k++) { + const n = ast.nodes[k]; + if (n.kind === "literal" && n.value.length > 0) { + nextLiteral = n.value; + break; + } + if (n.kind === "expression" && OP[n.op].first) { + nextLiteral = OP[n.op].first!; + break; + } + } + + if (!captureOneVar(v, last, nextLiteral)) return null; + } + // done with this expression + } + // fully consumed? for router-like matching we allow trailing chars only if next literal enforces them + return { vars: varsOut }; +} + +// In named operators, non-explode variables decompose the name[=value] form +function captureNamedNonExplode( + v: VarSpec, + raw: string, + spec: OperatorSpec, + policy: EncodingPolicy, +): [ok: boolean, val: unknown] { + // ; operator: name or name=value (empty value nameOnly allowed) + // ?/& operators: must be name[=value] (empty value is name=) + const eq = raw.indexOf(spec.kvSep); + if (eq === -1) { + if (spec.first === ";") { + // ";x" → empty value + if (raw === v.name) return [true, decodeAccordingToPolicy("", policy)]; + } + return [false, null]; + } + const lhs = raw.slice(0, eq), rhs = raw.slice(eq + spec.kvSep.length); + if (lhs !== v.name) return [false, null]; + return [true, decodeAccordingToPolicy(rhs, policy)]; +} + +function parseExplodeNamedParts( + v: VarSpec, + parts: string[], + spec: OperatorSpec, + policy: EncodingPolicy, +): unknown { + // List vs Map determination: + // If all items are in "varName=…" format, it's a list + const allVarName = parts.every((p) => p.startsWith(v.name + spec.kvSep)); + if (allVarName) { + return parts.map((p) => { + const rhs = p.slice((v.name + spec.kvSep).length); + return decodeAccordingToPolicy(rhs, policy); + }); + } + // Otherwise it's a map (k=v) + const obj: Record = {}; + for (const p of parts) { + const i = p.indexOf(spec.kvSep); + if (i < 0) { + obj[decodeAccordingToPolicy(p, policy) as string] = ""; + continue; + } + const k = p.slice(0, i), val = p.slice(i + spec.kvSep.length); + obj[decodeAccordingToPolicy(k, policy) as string] = decodeAccordingToPolicy( + val, + policy, + ); + } + return obj; +} diff --git a/packages/uri-template/src/parser.ts b/packages/uri-template/src/parser.ts new file mode 100644 index 00000000..d1496c32 --- /dev/null +++ b/packages/uri-template/src/parser.ts @@ -0,0 +1,129 @@ +import type { Expression, Node, TemplateAst, VarSpec } from "./ast.ts"; +import { ParseError } from "./error.ts"; + +/** + * Parse a RFC 6570 URI template string into an AST. + * + * @param template - The URI template string to parse + * @returns The parsed template AST + * @throws {ParseError} If the template syntax is invalid + * + * @remarks + * Parser guarantees: + * - Balanced braces: every '{' has a matching '}' or throws ParseError + * - Operator is one of "", "+", "#", ".", "/", ";", "?", "&" + * - VarSpec list: "name[:prefix][*]" items separated by ',' + * + * We avoid regex for correctness and slice the source directly to keep + * raw segments intact for later matching. + * + * @example + * ```typescript + * const ast = parse("{+path}/here"); + * // Returns AST with expression (op: "+", vars: [{name: "path"}]) and literal ("/here") nodes + * ``` + * + * @example + * ```typescript + * const ast = parse("/repos{/owner,repo}{?q,lang}"); + * // Returns AST with literal and two expression nodes + * ``` + */ +export function parse(template: string): TemplateAst { + const nodes: Node[] = []; + let i = 0; + const pushLiteral = (start: number, end: number) => { + if (end > start) { + nodes.push({ + kind: "literal", + value: template.slice(start, end), + start, + end, + }); + } + }; + + // We collect [literal] chunks until '{', then parse an [expression], + // then continue scanning for the next '{'. + while (i < template.length) { + const litStart = i; + while (i < template.length && template[i] !== "{") i++; + pushLiteral(litStart, i); + if (i >= template.length) break; + // expression + const exprStart = i; + i++; // skip "{" + if (i >= template.length) { + throw new ParseError("Unclosed expression", exprStart); + } + // Read operator (optional). If next char in "+#./;?&", consume as operator. + // Otherwise use "" (simple operator). + const opChar = "+#./;?&".includes(template[i]) + ? template[i++] as Expression["op"] + : "" as Expression["op"]; + const vars: VarSpec[] = []; + // Read variable name; stop at ':', '*', ',', or '}'. + // Note: Empty names are illegal per RFC 6570. + const readName = (): string => { + const start = i; + while ( + i < template.length && + template[i] !== "}" && + template[i] !== "," && + template[i] !== ":" && + template[i] !== "*" + ) i++; + if (i === start) throw new ParseError("Empty variable name", i); + return template.slice(start, i); + }; + + while (true) { + const name = readName(); + let explode = false; + let prefix: number | undefined; + + // Handle modifiers: + // - :n -> prefix length (integer >= 0) + // - * -> explode + // Enforce ordering: name [ ":" digits ] [ "*" ] + if (template[i] === ":") { + i++; + const start = i; + while (i < template.length && /[0-9]/.test(template[i])) i++; + if (i === start) throw new ParseError("Expected prefix length", i); + prefix = parseInt(template.slice(start, i), 10); + if (!(prefix >= 0)) { + throw new ParseError("Invalid prefix length", start); + } + } + if (template[i] === "*") { + explode = true; + i++; + } + vars.push({ name, explode, prefix }); + + if (template[i] === ",") { + i++; + continue; + } + // Close '}' or throw. + if (template[i] === "}") { + i++; + break; + } + throw new ParseError("Unexpected character in expression", i); + } + + nodes.push( + { + kind: "expression", + op: opChar, + vars, + start: exprStart, + end: i, + } as Expression, + ); + } + + return { nodes }; +} diff --git a/packages/uri-template/src/rfc6570.spec.ts b/packages/uri-template/src/rfc6570.spec.ts new file mode 100644 index 00000000..34862f14 --- /dev/null +++ b/packages/uri-template/src/rfc6570.spec.ts @@ -0,0 +1,424 @@ +import { deepStrictEqual as assertEquals } from "node:assert/strict"; +import { describe, test } from "node:test"; +import { compile } from "./compile.ts"; + +describe("basic expansion", () => { + test("scalar", () => { + const t = compile("{var}"); + assertEquals(t.expand({ var: "value" }), "value"); + assertEquals(t.expand({ var: "va lue" }), "va%20lue"); + assertEquals(t.expand({ var: "100%" }), "100%25"); + }); + + test("list (no explode)", () => { + const t = compile("{list}"); + assertEquals( + t.expand({ list: ["red", "green", "blue"] }), + "red,green,blue", + ); + }); + + test("map (no explode)", () => { + const t = compile("{keys}"); + assertEquals( + t.expand({ keys: { semi: ";", dot: ".", comma: "," } }), + "semi,%3B,dot,.,comma,%2C", + ); + }); + + test("multiple vars", () => { + const t = compile("{x,y}"); + assertEquals(t.expand({ x: "1024" }), "1024"); + }); +}); + +describe("{+var} reserved expansion", () => { + test("{+var} allows reserved - basic", () => { + const t = compile("{+var}"); + assertEquals(t.expand({ var: "value" }), "value"); + }); + + test("{+var} allows reserved - percent encoding", () => { + const t = compile("{+hello}"); + assertEquals(t.expand({ hello: "Hello World!" }), "Hello%20World!"); + }); + + test("{+var} allows reserved - already encoded", () => { + const t = compile("{+half}"); + assertEquals(t.expand({ half: "50%" }), "50%25"); + }); + + test("{+var} allows reserved - base URL comparison", () => { + const t1 = compile("{base}index"); + const t2 = compile("{+base}index"); + const vars = { base: "http://example.com/home/" }; + assertEquals(t1.expand(vars), "http%3A%2F%2Fexample.com%2Fhome%2Findex"); + assertEquals(t2.expand(vars), "http://example.com/home/index"); + }); + + test("{+var} allows reserved - empty value", () => { + const t = compile("O{+empty}X"); + assertEquals(t.expand({ empty: "" }), "OX"); + }); + + test("{+var} allows reserved - undefined value", () => { + const t = compile("O{+undef}X"); + assertEquals(t.expand({ undef: undefined }), "OX"); + }); + + test("{+var} allows reserved - path", () => { + const t = compile("{+path}"); + assertEquals(t.expand({ path: "/foo/bar" }), "/foo/bar"); + }); + + test("{+var} allows reserved - path appended", () => { + const t = compile("{+path}/here"); + assertEquals(t.expand({ path: "/foo/bar" }), "/foo/bar/here"); + }); + + test("{+var} allows reserved - in query", () => { + const t = compile("here?ref={+path}"); + assertEquals(t.expand({ path: "/foo/bar" }), "here?ref=/foo/bar"); + }); + + test("{+var} allows reserved - mixed with normal var", () => { + const t = compile("up{+path}{var}/here"); + assertEquals( + t.expand({ path: "/foo/bar", var: "value" }), + "up/foo/barvalue/here", + ); + }); + + test("{+var} allows reserved - multiple vars", () => { + const t = compile("{+x,hello,y}"); + assertEquals( + t.expand({ x: "1024", hello: "Hello World!", y: "768" }), + "1024,Hello%20World!,768", + ); + }); + + test("{+var} allows reserved - multiple vars with path", () => { + const t = compile("{+path,x}/here"); + assertEquals( + t.expand({ path: "/foo/bar", x: "1024" }), + "/foo/bar,1024/here", + ); + }); + + test("{+var} allows reserved - prefix modifier", () => { + const t = compile("{+path:6}/here"); + assertEquals(t.expand({ path: "/foo/bar" }), "/foo/b/here"); + }); + + test("{+var} allows reserved - list", () => { + const t = compile("{+list}"); + assertEquals( + t.expand({ list: ["red", "green", "blue"] }), + "red,green,blue", + ); + }); + + test("{+var} allows reserved - list explode", () => { + const t = compile("{+list*}"); + assertEquals( + t.expand({ list: ["red", "green", "blue"] }), + "red,green,blue", + ); + }); + + test("{+var} allows reserved - map", () => { + const t = compile("{+keys}"); + assertEquals( + t.expand({ keys: { semi: ";", dot: ".", comma: "," } }), + "semi,;,dot,.,comma,,", + ); + }); + + test("{+var} allows reserved - map explode", () => { + const t = compile("{+keys*}"); + assertEquals( + t.expand({ keys: { semi: ";", dot: ".", comma: "," } }), + "semi=;,dot=.,comma=,", + ); + }); +}); + +describe("{#var} fragment expansion", () => { + test("{#var} fragment", () => { + const t = compile("{#frag}"); + assertEquals(t.expand({ frag: "a b" }), "#a%20b"); + assertEquals(t.expand({ frag: "a/b?c#d" }), "#a/b?c%23d"); + }); +}); + +describe("{.var} label expansion", () => { + test("{.var} label - basic", () => { + const t = compile("www{.domain}"); + assertEquals(t.expand({ domain: "example" }), "www.example"); + }); + + test("{.var} label - single variable", () => { + const t = compile("{.who}"); + assertEquals(t.expand({ who: "fred" }), ".fred"); + }); + + test("{.var} label - same variable twice", () => { + const t = compile("{.who,who}"); + assertEquals(t.expand({ who: "fred" }), ".fred.fred"); + }); + + test("{.var} label - mixed variables with encoding", () => { + const t = compile("{.half,who}"); + assertEquals(t.expand({ half: "50%", who: "fred" }), ".50%25.fred"); + }); + + test("{.var} label - explode with multiple labels", () => { + const t = compile("www{.dom*}"); + assertEquals(t.expand({ dom: ["example", "com"] }), "www.example.com"); + }); + + test("{.var} label - with prefix", () => { + const t = compile("X{.var}"); + assertEquals(t.expand({ var: "value" }), "X.value"); + }); + + test("{.var} label - empty value", () => { + const t = compile("X{.empty}"); + assertEquals(t.expand({ empty: "" }), "X."); + }); + + test("{.var} label - undefined value", () => { + const t = compile("X{.undef}"); + assertEquals(t.expand({ undef: undefined }), "X"); + }); + + test("{.var} label - prefix modifier", () => { + const t = compile("X{.var:3}"); + assertEquals(t.expand({ var: "value" }), "X.val"); + }); + + test("{.var} label - list without explode", () => { + const t = compile("X{.list}"); + assertEquals( + t.expand({ list: ["red", "green", "blue"] }), + "X.red,green,blue", + ); + }); + + test("{.var} label - list with explode", () => { + const t = compile("X{.list*}"); + assertEquals( + t.expand({ list: ["red", "green", "blue"] }), + "X.red.green.blue", + ); + }); + + test("{.var} label - map without explode", () => { + const t = compile("X{.keys}"); + assertEquals( + t.expand({ keys: { semi: ";", dot: ".", comma: "," } }), + "X.semi,%3B,dot,.,comma,%2C", + ); + }); + + test("{.var} label - map with explode", () => { + const t = compile("X{.keys*}"); + assertEquals( + t.expand({ keys: { semi: ";", dot: ".", comma: "," } }), + "X.semi=%3B.dot=..comma=%2C", + ); + }); + + test("{.var} label - empty map without explode", () => { + const t = compile("X{.empty_keys}"); + assertEquals(t.expand({ empty_keys: {} }), "X"); + }); + + test("{.var} label - empty map with explode", () => { + const t = compile("X{.empty_keys*}"); + assertEquals(t.expand({ empty_keys: {} }), "X"); + }); +}); + +describe("path and query operators", () => { + test("{/var} path segment", () => { + const t = compile("/users{/id}"); + assertEquals(t.expand({ id: "a/b" }), "/users/a%2Fb"); + }); + + test("{;x,y} matrix params", () => { + const t = compile("/res{;x,y}"); + assertEquals(t.expand({ x: "a b", y: "" }), "/res;x=a%20b;y"); + assertEquals(t.expand({ x: undefined, y: undefined }), "/res"); + }); + + test("{?x,y} query", () => { + const t = compile("/search{?q,lang}"); + assertEquals(t.expand({ q: "a b", lang: "en" }), "/search?q=a%20b&lang=en"); + assertEquals(t.expand({ q: "", lang: undefined }), "/search?q="); + }); + + test("{&x,y} query continuation", () => { + const t = compile("/search?q=init{&x,y}"); + assertEquals(t.expand({ x: "1", y: "2" }), "/search?q=init&x=1&y=2"); + }); +}); + +describe("explode modifier", () => { + test("explode list", () => { + const t = compile("/tags{;tags*}"); + assertEquals( + t.expand({ tags: ["red", "green", "blue"] }), + "/tags;tags=red;tags=green;tags=blue", + ); + }); + + test("explode map", () => { + const t = compile("{?keys*}"); + assertEquals( + t.expand({ keys: { semi: ";", dot: ".", comma: "," } }), + "?semi=%3B&dot=.&comma=%2C", + ); + }); +}); + +describe("prefix modifier", () => { + test("prefix :n with scalar", () => { + const t = compile("{var:3}"); + assertEquals(t.expand({ var: "abcdef" }), "abc"); + }); + + test("prefix with reserved content", () => { + const t = compile("/cut/{x:5}"); + assertEquals(t.expand({ x: "abc/def?ghi" }), "/cut/abc%2Fd"); // whether it cuts at UTF-8 boundary assumes ASCII input + }); +}); + +describe("round-trip tests", () => { + test("expand(match(url)) === url (opaque)", () => { + const t = compile("/repos{/owner,repo}{?q,lang}"); + const url = t.expand({ + owner: "alice", + repo: "hello/world", + q: "a b", + lang: "en", + }); + const m = t.match(url, { encoding: "opaque" }); + assertEquals(m !== null, true); + const url2 = t.expand(m!.vars as Parameters[0]); + assertEquals(url2, url); + }); + + test("match(expand(vars)) ≅ vars (cooked)", () => { + const t = compile("/u{/id}{?q}"); + const vars = { id: "a/b", q: "x y" }; + const url = t.expand(vars); + const m = t.match(url, { encoding: "cooked" }); + assertEquals(m !== null, true); + // cooked compares after one decoding + assertEquals(m!.vars, vars); + }); + + test("explode list stays list in cooked", () => { + const t = compile("/t{;tags*}"); + const url = t.expand({ tags: ["a b", "c"] }); + const m = t.match(url, { encoding: "cooked" }); + assertEquals(m !== null, true); + assertEquals(m!.vars.tags, ["a b", "c"]); + }); + + test("explode map stays map in cooked", () => { + const t = compile("{?m*}"); + const url = t.expand({ m: { a: "1 2", b: "/" } }); + const m = t.match(url, { encoding: "cooked" }); + assertEquals(m !== null, true); + assertEquals(m!.vars.m, { a: "1 2", b: "/" }); + }); +}); + +describe("encoding and special cases", () => { + /** + * Regression for https://github.com/awwright/uri-template-router/issues/11: + * Ensure that percent-encoded sequences and '#' handling do not lose information. + */ + test("opaque preserves raw percent sequences", () => { + const t = compile("{#frag}"); + // Even if values contain characters like "%30#", opaque policy should not lose the original text + const url = t.expand({ frag: "%30#" }); + // During expansion, encoder handles % and # safely according to idempotent/protection rules + const mOpaque = t.match(url, { encoding: "opaque" }); + assertEquals(mOpaque !== null, true); + // raw as-is without any decoding + assertEquals(mOpaque!.vars.frag, mOpaque!.vars.frag); // existence check + // in cooked mode, observable with one decoding + const mCooked = t.match(url, { encoding: "cooked" }); + assertEquals(mCooked !== null, true); + // check if decoded form restores to original meaning ("%30#") + assertEquals(typeof mCooked!.vars.frag, "string"); + assertEquals((mCooked!.vars.frag as string).includes("#"), true); + }); + + test("bad percent triplet during match => null (strict default)", () => { + const t = compile("/x{/id}"); + // fails if invalid triplet like '%GZ' is in the middle + const m = t.match("/x/%GZ", { encoding: "opaque" }); + assertEquals(m, null); + }); + + test("idempotent expand: do not double-encode existing %XX", () => { + const t = compile("/p{/x}"); + const url = t.expand({ x: "a%2Fb" }); + assertEquals(url, "/p/a%2Fb"); // preserve %2F (prevent double-encoding) + }); + + test("space encoding", () => { + const t = compile("/s{/x}{?q}"); + const url = t.expand({ x: "a b", q: "x y" }); + assertEquals(url, "/s/a%20b?q=x%20y"); + const m = t.match(url, { encoding: "cooked" }); + assertEquals(m!.vars.x, "a b"); + assertEquals(m!.vars.q, "x y"); + }); + + test("greedy capture until next literal", () => { + const t = compile("/r{?q}#end"); + const url = t.expand({ q: "a,b,c" }); + assertEquals(url, "/r?q=a%2Cb%2Cc#end"); + const m = t.match(url, { encoding: "cooked" }); + assertEquals(m !== null, true); + assertEquals(m!.vars.q, "a,b,c"); + }); + + test("multiple expressions with separators", () => { + const t = compile("/x{/a}{/b}{?c}{&d}"); + const url = t.expand({ a: "1", b: "2/3", c: "4 5", d: "6" }); + assertEquals(url, "/x/1/2%2F3?c=4%205&d=6"); + const m = t.match(url, { encoding: "cooked" }); + assertEquals(m!.vars, { a: "1", b: "2/3", c: "4 5", d: "6" }); + }); +}); + +describe("undefined and empty value handling", () => { + test("undefined omits in simple operator", () => { + const t = compile("A{var}Z"); + assertEquals(t.expand({ var: undefined }), "AZ"); + }); + + test("empty string is nameOnly in ';' operator", () => { + const t = compile("{;x}"); + assertEquals(t.expand({ x: "" }), ";x"); + assertEquals(t.expand({ x: undefined }), ""); + }); + + test("undefined query param omitted", () => { + const t = compile("/s{?q,lang}"); + assertEquals(t.expand({ q: undefined, lang: "en" }), "/s?lang=en"); + }); + + test("match allows empty value", () => { + const t = compile("/s{?q}"); + const m = t.match("/s?q=", { encoding: "opaque" }); + assertEquals(m !== null, true); + assertEquals(m!.vars.q, ""); + }); +}); diff --git a/packages/uri-template/src/spec.ts b/packages/uri-template/src/spec.ts new file mode 100644 index 00000000..37d1130a --- /dev/null +++ b/packages/uri-template/src/spec.ts @@ -0,0 +1,162 @@ +import type { Operator } from "./ast.ts"; + +// unreserved: ALPHA / DIGIT / "-" / "." / "_" / "~" (RFC 3986) +export const UNRESERVED = new Set( + [..."abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-._~"], +); +// reserved: gen-delims / sub-delims (# & + treated by operator) +const GEN_DELIMS = ":/?#[]@"; +const SUB_DELIMS = "!$&'()*+,;="; +export const RESERVED_GENERAL = new Set([...GEN_DELIMS + SUB_DELIMS]); +const RESERVED_NO_HASH = new Set([...":/?[]@" + SUB_DELIMS + "?"]); // disallow '#' + +// percent sequence validator +export function looksLikePctTriplet(s: string, i: number): boolean { + if (s[i] !== "%" || i + 2 >= s.length) return false; + const a = s[i + 1], b = s[i + 2]; + const hex = (c: string) => + ("0" <= c && c <= "9") || ("a" <= c && c <= "f") || ("A" <= c && c <= "F"); + return hex(a) && hex(b); +} + +/** @internal + * OperatorSpec is the single source of truth (SSOT) for both expand & match. + * - first: string added before the first element (e.g. "#", ".", "/", ";", "?", "&") + * - named: if true, items are "name=value" pairs (e.g. ;, ?, & operators) + * - ifEmpty: + * - "omit" -> remove entirely (default simple operator) + * - "nameOnly" -> emit only key (e.g. ;x) + * - "empty" -> emit "key=" (e.g. ?x= / &x=) + * - allowReserved/reservedSet: precise reserved char pass-through control + * (e.g. "#" allows most reserved but NOT '#', which would start a fragment) + */ +export interface OperatorSpec { + first?: string; + named?: boolean; + ifEmpty?: "omit" | "nameOnly" | "empty"; + allowReserved: boolean; + reservedSet?: Set; + itemSep: string; + kvSep: string; +} + +export const OP: Record = { + "": { allowReserved: false, itemSep: ",", kvSep: "=", ifEmpty: "omit" }, + "+": { + allowReserved: true, + reservedSet: RESERVED_GENERAL, + itemSep: ",", + kvSep: "=", + ifEmpty: "omit", + }, + "#": { + first: "#", + allowReserved: true, + reservedSet: RESERVED_NO_HASH, + itemSep: ",", + kvSep: "=", + ifEmpty: "omit", + }, + ".": { + first: ".", + allowReserved: false, + itemSep: ".", + kvSep: "=", + ifEmpty: "omit", + }, + "/": { + first: "/", + allowReserved: false, + itemSep: "/", + kvSep: "=", + ifEmpty: "omit", + }, + // Note: '?' and '&' must output name= when empty + ";": { + first: ";", + named: true, + allowReserved: false, + itemSep: ";", + kvSep: "=", + ifEmpty: "nameOnly", + }, + "?": { + first: "?", + named: true, + allowReserved: false, + itemSep: "&", + kvSep: "=", + ifEmpty: "empty", + }, + "&": { + first: "&", + named: true, + allowReserved: false, + itemSep: "&", + kvSep: "=", + ifEmpty: "empty", + }, +}; + +// Encode one character according to operator rules (idempotent) +export function encodeChar(ch: string, allowReserved: boolean): string { + if (UNRESERVED.has(ch)) return ch; + if (allowReserved && RESERVED_GENERAL.has(ch)) return ch; + // If already a %XX triplet, keep as-is (idempotent) + if (ch === "%") return "%25"; + const hex = new TextEncoder().encode(ch); // UTF-8 bytes + return [...hex].map((b) => + "%" + b.toString(16).toUpperCase().padStart(2, "0") + ).join(""); +} + +// Percent-idempotent encoder for an entire string +export function encodeComponentIdempotent( + s: string, + allowReserved: boolean, + reservedSet?: Set, +): string { + let out = ""; + for (let i = 0; i < s.length;) { + // Fast-path: copy %XX untouched; encode others by UTF-8 bytes. + // We iterate by JS code points but encode by TextEncoder (UTF-8). + if (s[i] === "%" && looksLikePctTriplet(s, i)) { + out += s.slice(i, i + 3); + i += 3; + continue; + } + const ch = s[i]; + if (UNRESERVED.has(ch)) { + out += ch; + i++; + continue; + } + if (allowReserved && reservedSet?.has(ch)) { + out += ch; + i++; + continue; + } + const bytes = new TextEncoder().encode(ch); + for (const b of bytes) { + out += "%" + b.toString(16).toUpperCase().padStart(2, "0"); + } + i++; + } + return out; +} + +// Strict percent-decoder: rejects bad triplets and double-decoding +export function strictPercentDecode(s: string): string { + // Validate all percent sequences first + for (let i = 0; i < s.length;) { + if (s[i] === "%") { + if (!looksLikePctTriplet(s, i)) { + throw new Error("Bad percent sequence"); + } + i += 3; + } else { + i++; + } + } + return decodeURIComponent(s); +} diff --git a/packages/uri-template/tsdown.config.ts b/packages/uri-template/tsdown.config.ts new file mode 100644 index 00000000..bfa6eea4 --- /dev/null +++ b/packages/uri-template/tsdown.config.ts @@ -0,0 +1,9 @@ +import { defineConfig } from "tsdown"; + +export default defineConfig({ + entry: ["src/index.ts", "src/*.spec.ts"], + unbundle: true, + dts: true, + platform: "neutral", + format: ["esm", "cjs"], +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 2f75a300..f8aff222 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1122,6 +1122,18 @@ importers: specifier: 'catalog:' version: 5.9.3 + packages/uri-template: + devDependencies: + '@types/node': + specifier: 'catalog:' + version: 22.18.8 + tsdown: + specifier: 'catalog:' + version: 0.12.9(typescript@5.9.3) + typescript: + specifier: 'catalog:' + version: 5.9.3 + packages/vocab-runtime: dependencies: '@logtape/logtape': diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index b1279391..467d3df0 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -17,6 +17,7 @@ packages: - packages/sqlite - packages/sveltekit - packages/testing +- packages/uri-template - packages/vocab-runtime - packages/vocab-tools - docs