|
6 | 6 | * found in the LICENSE file at https://angular.dev/license |
7 | 7 | */ |
8 | 8 |
|
| 9 | +import { DefaultUrlSerializer } from '@angular/router'; |
| 10 | + |
9 | 11 | /** |
10 | 12 | * Removes the trailing slash from a URL if it exists. |
11 | 13 | * |
@@ -153,123 +155,80 @@ export function stripIndexHtmlFromURL(url: URL): URL { |
153 | 155 | } |
154 | 156 |
|
155 | 157 | /** |
156 | | - * Matches a `*<n>` placeholder that makes up a whole path segment. |
| 158 | + * Cached instance of `DefaultUrlSerializer` used for query parameter parsing and serialization. |
157 | 159 | */ |
158 | | -const POSITIONAL_PLACEHOLDER_REGEXP = /^\*(\d+)$/; |
| 160 | +let urlSerializer: DefaultUrlSerializer | undefined; |
159 | 161 |
|
160 | 162 | /** |
161 | | - * Resolves `*` placeholders in a path template by mapping them to corresponding segments |
162 | | - * from a base path. This is useful for constructing paths dynamically based on a given base path. |
163 | | - * |
164 | | - * The function processes the `toPath` string, replacing each `*` placeholder with |
165 | | - * the corresponding segment from the `fromPath`, and each `*<n>` placeholder with the |
166 | | - * segment at position `n` of the `fromPath`. If the `toPath` contains no placeholders, |
167 | | - * it is returned as-is. Invalid `toPath` formats (not starting with `/`) will throw an error. |
168 | | - * |
169 | | - * @param toPath - A path template string that may contain `*` placeholders. Each `*` is replaced |
170 | | - * by the corresponding segment from the `fromPath`. Static paths (e.g., `/static/path`) are returned |
171 | | - * directly without placeholder replacement. |
172 | | - * @param fromPath - A base path string, split into segments, that provides values for |
173 | | - * replacing `*` placeholders in the `toPath`. |
174 | | - * @returns A resolved path string with `*` placeholders replaced by segments from the `fromPath`, |
175 | | - * or the `toPath` returned unchanged if it contains no placeholders. |
| 163 | + * Resolves `*` and `*<n>` placeholders in the path of `toUrl` from `fromPath`, and `:name` |
| 164 | + * placeholders in the query of `toUrl` from `fromSearch`, matching Angular router redirect semantics. |
176 | 165 | * |
177 | | - * @throws If the `toPath` does not start with a `/`, indicating an invalid path format. |
| 166 | + * - Each `*` path segment is replaced with the segment at the same index in `fromPath`. |
| 167 | + * - Each `*<n>` path segment is replaced with the segment at index `n` in `fromPath`. |
| 168 | + * - When `fromSearch` is provided, each query parameter whose value is `:name` is replaced with the |
| 169 | + * values of `name` from `fromSearch`, or `undefined` if missing. Repeated query parameters are not |
| 170 | + * resolved, matching the Angular router. |
178 | 171 | * |
179 | | - * @example |
180 | | - * ```typescript |
181 | | - * // Example with placeholders resolved |
182 | | - * const resolvedPath = buildPathWithParams('/*\/details', '/123/abc'); |
183 | | - * console.log(resolvedPath); // Outputs: '/123/details' |
184 | | - * |
185 | | - * // Example with a positional placeholder |
186 | | - * const positionalPath = buildPathWithParams('/user/*1', '/123/abc'); |
187 | | - * console.log(positionalPath); // Outputs: '/user/123' |
188 | | - * |
189 | | - * // Example with a static path |
190 | | - * const staticPath = buildPathWithParams('/static/path', '/base/unused'); |
191 | | - * console.log(staticPath); // Outputs: '/static/path' |
192 | | - * ``` |
| 172 | + * @param toUrl - A URL template string starting with `/`. |
| 173 | + * @param fromPath - A base path string starting with `/` that provides segments for path placeholders. |
| 174 | + * @param fromSearch - Optional query string (e.g. `url.search`) that provides values for query placeholders. |
| 175 | + * @returns The URL with its path and query placeholders resolved. |
193 | 176 | */ |
194 | | -export function buildPathWithParams(toPath: string, fromPath: string): string { |
195 | | - if (toPath[0] !== '/') { |
196 | | - throw new Error(`Invalid toPath: The string must start with a '/'. Received: '${toPath}'`); |
| 177 | +export function buildUrlWithParams(toUrl: string, fromPath: string, fromSearch?: string): string { |
| 178 | + if (toUrl[0] !== '/') { |
| 179 | + throw new Error(`Invalid toUrl: The string must start with a '/'. Received: '${toUrl}'`); |
197 | 180 | } |
198 | 181 |
|
199 | 182 | if (fromPath[0] !== '/') { |
200 | 183 | throw new Error(`Invalid fromPath: The string must start with a '/'. Received: '${fromPath}'`); |
201 | 184 | } |
202 | 185 |
|
203 | | - const [path] = toPath.split(/[?#]/, 1); |
204 | | - if (!path.includes('/*')) { |
205 | | - return toPath; |
| 186 | + const hasPathParams = toUrl.includes('/*'); |
| 187 | + const hasQueryParams = Boolean(fromSearch !== undefined && toUrl.includes('=:')); |
| 188 | + if (!hasPathParams && !hasQueryParams) { |
| 189 | + return toUrl; |
206 | 190 | } |
207 | 191 |
|
208 | | - const fromPathParts = fromPath.split('/'); |
209 | | - const toPathParts = path.split('/'); |
210 | | - const resolvedParts = toPathParts.map((part, index) => |
211 | | - part === '*' |
212 | | - ? fromPathParts[index] |
213 | | - : part.replace(POSITIONAL_PLACEHOLDER_REGEXP, (_, position) => fromPathParts[position]), |
214 | | - ); |
| 192 | + const searchIndex = toUrl.search(/[?#]/); |
| 193 | + let path = searchIndex === -1 ? toUrl : toUrl.slice(0, searchIndex); |
| 194 | + let suffix = searchIndex === -1 ? '' : toUrl.slice(searchIndex); |
215 | 195 |
|
216 | | - return joinUrlParts(...resolvedParts) + toPath.slice(path.length); |
217 | | -} |
218 | | - |
219 | | -/** |
220 | | - * Resolves `:name` placeholders in the query of a URL, like the Angular router does for redirects. |
221 | | - * |
222 | | - * Each query parameter whose value is a `:name` placeholder gets the values of the `name` |
223 | | - * query parameter of `fromParams`, or `undefined` if `fromParams` has no such parameter. |
224 | | - * Like in the Angular router, placeholders of repeated query parameters are not resolved. |
225 | | - * |
226 | | - * @param toUrl - A URL whose query may contain `:name` placeholders. |
227 | | - * @param fromParams - The query parameters that provide the values for the placeholders. |
228 | | - * @returns The URL with its query placeholders resolved. |
229 | | - * |
230 | | - * @example |
231 | | - * ```typescript |
232 | | - * buildQueryWithParams('/find?q=:x', new URLSearchParams('x=cats')); // returns '/find?q=cats' |
233 | | - * ``` |
234 | | - */ |
235 | | -export function buildQueryWithParams(toUrl: string, fromParams: URLSearchParams): string { |
236 | | - const [, path, query, fragment] = /^([^?#]*)\?([^#]*)(.*)$/.exec(toUrl) ?? []; |
237 | | - if (query === undefined) { |
238 | | - return toUrl; |
239 | | - } |
| 196 | + if (hasPathParams && path.includes('/*')) { |
| 197 | + const fromPathParts = fromPath.split('/'); |
| 198 | + const toPathParts = path.split('/'); |
| 199 | + const resolvedParts = toPathParts.map((part, index) => { |
| 200 | + if (part[0] !== '*') { |
| 201 | + return part; |
| 202 | + } |
240 | 203 |
|
241 | | - const params = query.split('&'); |
242 | | - const names = params.map((param) => param.split('=', 1)[0]); |
243 | | - const resolvedQuery = params |
244 | | - .map((param, index) => { |
245 | | - const name = names[index]; |
246 | | - const value = param.slice(name.length + 1); |
247 | | - if (value[0] !== ':' || names.indexOf(name) !== names.lastIndexOf(name)) { |
248 | | - return param; |
| 204 | + if (part.length === 1) { |
| 205 | + return fromPathParts[index]; |
249 | 206 | } |
250 | 207 |
|
251 | | - const sourceValues = fromParams.getAll(value.slice(1)); |
| 208 | + const position = Number(part.slice(1)); |
252 | 209 |
|
253 | | - return (sourceValues.length ? sourceValues : ['undefined']) |
254 | | - .map((sourceValue) => `${name}=${encodeQueryValue(sourceValue)}`) |
255 | | - .join('&'); |
256 | | - }) |
257 | | - .join('&'); |
| 210 | + return Number.isInteger(position) ? fromPathParts[position] : part; |
| 211 | + }); |
258 | 212 |
|
259 | | - return `${path}?${resolvedQuery}${fragment}`; |
260 | | -} |
| 213 | + path = joinUrlParts(...resolvedParts); |
| 214 | + } |
261 | 215 |
|
262 | | -/** |
263 | | - * Encodes a query parameter value like the Angular router (`DefaultUrlSerializer`), which keeps |
264 | | - * `@`, `:`, `$`, `,` and `;` as is. |
265 | | - * |
266 | | - * @param value - The query parameter value to encode. |
267 | | - * @returns The encoded query parameter value. |
268 | | - */ |
269 | | -function encodeQueryValue(value: string): string { |
270 | | - return encodeURIComponent(value).replace(/%(40|3A|24|2C|3B)/g, (match) => |
271 | | - decodeURIComponent(match), |
272 | | - ); |
| 216 | + if (hasQueryParams && suffix.includes('=:')) { |
| 217 | + urlSerializer ??= new DefaultUrlSerializer(); |
| 218 | + const toTree = urlSerializer.parse(suffix); |
| 219 | + const actualParams = fromSearch ? urlSerializer.parse(fromSearch).queryParams : {}; |
| 220 | + |
| 221 | + for (const [key, value] of Object.entries(toTree.queryParams)) { |
| 222 | + if (typeof value === 'string' && value[0] === ':') { |
| 223 | + toTree.queryParams[key] = actualParams[value.slice(1)]; |
| 224 | + } |
| 225 | + } |
| 226 | + |
| 227 | + // `serialize(toTree)` returns `/?...#...` because `toTree.root` is empty. |
| 228 | + suffix = urlSerializer.serialize(toTree).slice(1); |
| 229 | + } |
| 230 | + |
| 231 | + return path + suffix; |
273 | 232 | } |
274 | 233 |
|
275 | 234 | const MATRIX_PARAMS_REGEX = /;[^/]+/g; |
|
0 commit comments