| 1 | /** |
| 2 | * Helpers for escaping JSON Pointer path segments and converting JSON Pointer |
| 3 | * URI fragments. JSON Pointer uses `/` to separate path tokens inside a JSON |
| 4 | * document, so token text must encode literal `~` and `/` characters. URI |
| 5 | * fragments additionally apply percent-encoding after JSON Pointer escaping. |
| 6 | * |
| 7 | * @since 4.0.0 |
| 8 | */ |
| 9 | |
| 10 | /** |
| 11 | * Escapes a JSON Pointer reference token according to RFC 6901 by encoding special characters so the token can be safely used as a segment in a JSON Pointer. |
| 12 | * |
| 13 | * **When to use** |
| 14 | * |
| 15 | * Use when you need to escape a single JSON Pointer path segment. |
| 16 | * |
| 17 | * **Details** |
| 18 | * |
| 19 | * - Returns a new escaped string |
| 20 | * - Replaces `~` (tilde) with `~0` and `/` (forward slash) with `~1` |
| 21 | * - Returns the input unchanged if it contains no special characters |
| 22 | * - Empty strings are valid and returned unchanged |
| 23 | * |
| 24 | * **Gotchas** |
| 25 | * |
| 26 | * The replacement order matters: `~` is replaced before `/` to prevent double-escaping. |
| 27 | * |
| 28 | * **Example** (Escaping special characters) |
| 29 | * |
| 30 | * ```ts import.meta.vitest |
| 31 | * import { JsonPointer } from "effect" |
| 32 | * |
| 33 | * JsonPointer.escapeToken("a/b") // => "a~1b" |
| 34 | * JsonPointer.escapeToken("c~d") // => "c~0d" |
| 35 | * JsonPointer.escapeToken("path/to~key") // => "path~1to~0key" |
| 36 | * ``` |
| 37 | * |
| 38 | * @see {@link unescapeToken} The inverse operation for decoding escaped tokens |
| 39 | * @category encoding |
| 40 | * @since 4.0.0 |
| 41 | */ |
| 42 | export function escapeToken(token: string): string { |
| 43 | return token.replace(/~/g, "~0").replace(/\//g, "~1") |
| 44 | } |
| 45 | |
| 46 | /** |
| 47 | * Decodes a JSON Pointer reference token according to RFC 6901 escaping rules. |
| 48 | * |
| 49 | * **When to use** |
| 50 | * |
| 51 | * Use when you need to decode a single escaped JSON Pointer path segment. |
| 52 | * |
| 53 | * **Details** |
| 54 | * |
| 55 | * - Returns a new unescaped string |
| 56 | * - Replaces `~1` with `/` (forward slash) and `~0` with `~` (tilde) |
| 57 | * - Returns the input unchanged if it contains no escaped sequences |
| 58 | * - Empty strings are valid and returned unchanged |
| 59 | * |
| 60 | * **Gotchas** |
| 61 | * |
| 62 | * The replacement order matters: `~1` is replaced before `~0` to prevent incorrect decoding. |
| 63 | * |
| 64 | * **Example** (Unescaping special characters) |
| 65 | * |
| 66 | * ```ts import.meta.vitest |
| 67 | * import { JsonPointer } from "effect" |
| 68 | * |
| 69 | * JsonPointer.unescapeToken("a~1b") // => "a/b" |
| 70 | * JsonPointer.unescapeToken("c~0d") // => "c~d" |
| 71 | * JsonPointer.unescapeToken("path~1to~0key") // => "path/to~key" |
| 72 | * ``` |
| 73 | * |
| 74 | * @see {@link escapeToken} The inverse operation for encoding tokens |
| 75 | * @category decoding |
| 76 | * @since 4.0.0 |
| 77 | */ |
| 78 | export function unescapeToken(token: string): string { |
| 79 | return token.replace(/~1/g, "/").replace(/~0/g, "~") |
| 80 | } |
| 81 | |
| 82 | /** @internal */ |
| 83 | export function formatUriFragmentToken(token: string): string { |
| 84 | return encodeURI(escapeToken(token)).replace(/#/g, "%23") |
| 85 | } |
| 86 | |
| 87 | /** @internal */ |
| 88 | export function decodeUriFragment(fragment: string): string | undefined { |
| 89 | if (fragment.length === 0 || fragment === "#") return "" |
| 90 | if (!fragment.startsWith("#")) return undefined |
| 91 | const encoded = fragment.slice(1) |
| 92 | try { |
| 93 | if (encodeURI(encoded).replace(/%25/g, "%").replace(/#/g, "%23") !== encoded) return undefined |
| 94 | const pointer = decodeURIComponent(encoded) |
| 95 | return pointer.startsWith("/") && !/~(?:[^01]|$)/.test(pointer) ? pointer : undefined |
| 96 | } catch { |
| 97 | return undefined |
| 98 | } |
| 99 | } |
| 100 | |
| 101 | /** |
| 102 | * Parses a JSON Pointer URI fragment into decoded path tokens. |
| 103 | * |
| 104 | * **When to use** |
| 105 | * |
| 106 | * Use when you need to resolve a URI fragment against a JSON document. |
| 107 | * |
| 108 | * **Details** |
| 109 | * |
| 110 | * Percent-encoding is decoded before the pointer is split into tokens, then |
| 111 | * each token is decoded with {@link unescapeToken}. The empty string and `#` |
| 112 | * both represent the document root. |
| 113 | * |
| 114 | * **Gotchas** |
| 115 | * |
| 116 | * Returns `undefined` when the input is not a URI fragment, contains characters |
| 117 | * that require percent-encoding, or contains an invalid JSON Pointer escape |
| 118 | * sequence. |
| 119 | * |
| 120 | * **Example** (Parsing URI fragments) |
| 121 | * |
| 122 | * ```ts import.meta.vitest |
| 123 | * import { JsonPointer } from "effect" |
| 124 | * |
| 125 | * JsonPointer.parseUriFragment("#/users/a~1b") // => ["users", "a/b"] |
| 126 | * JsonPointer.parseUriFragment("#/caf%C3%A9") // => ["café"] |
| 127 | * JsonPointer.parseUriFragment("#/%") // => undefined |
| 128 | * JsonPointer.parseUriFragment("#/a#b") // => undefined |
| 129 | * ``` |
| 130 | * |
| 131 | * @see {@link formatUriFragment} for the inverse operation |
| 132 | * @category decoding |
| 133 | * @since 4.0.0 |
| 134 | */ |
| 135 | export function parseUriFragment(fragment: string): ReadonlyArray<string> | undefined { |
| 136 | const pointer = decodeUriFragment(fragment) |
| 137 | return pointer === undefined ? undefined : pointer.length === 0 ? [] : pointer.slice(1).split("/").map(unescapeToken) |
| 138 | } |
| 139 | |
| 140 | /** |
| 141 | * Formats path tokens as a JSON Pointer URI fragment. |
| 142 | * |
| 143 | * **When to use** |
| 144 | * |
| 145 | * Use when you need a URI fragment that identifies a value in a JSON document. |
| 146 | * |
| 147 | * **Details** |
| 148 | * |
| 149 | * Each token is encoded with {@link escapeToken} before URI percent-encoding |
| 150 | * is applied. An empty path is formatted as `#`. |
| 151 | * |
| 152 | * **Gotchas** |
| 153 | * |
| 154 | * Throws a `URIError` when a token contains an unpaired surrogate. |
| 155 | * |
| 156 | * **Example** (Formatting a URI fragment) |
| 157 | * |
| 158 | * ```ts import.meta.vitest |
| 159 | * import { JsonPointer } from "effect" |
| 160 | * |
| 161 | * JsonPointer.formatUriFragment(["users", "a/b", "Rate%"]) // => "#/users/a~1b/Rate%25" |
| 162 | * ``` |
| 163 | * |
| 164 | * @see {@link parseUriFragment} for the inverse operation |
| 165 | * @category encoding |
| 166 | * @since 4.0.0 |
| 167 | */ |
| 168 | export function formatUriFragment(path: ReadonlyArray<string>): string { |
| 169 | return path.reduce( |
| 170 | (fragment, token) => `${fragment}/${formatUriFragmentToken(token)}`, |
| 171 | "#" |
| 172 | ) |
| 173 | } |