Repository: tighten/ziggy (js)

Issue:
Ziggy generates URL with a double leading slash
### Ziggy version

v2.1.0

### Laravel version

v10.48.4

### Description

We have a route whose sole parameter comes from the DB:

```php
Route::get('{catalog_uri}', fn() => view('pdp'))
    ->where('catalog_uri', '^/p/.+$')
    ->name('catalog.pdp');
```
It's admittedly non-standard as you would usually move `/p/` as part of the route, but this prefix is part of the data provided by the catalog.

In Laravel, creating a URL to this route works as you would expect:
```php
route('catalog.pdp', ['catalog_uri' => '/p/test']); // http://example.com/p/test
```

Yet if generated by Ziggy, a URL with a double-slash is generated:
```js
route('catalog.pdp', { catalog_uri: '/p/test' }); // http://example.com//p/test
```


### Ziggy call and context

```js
We use Ziggy in Vue, so its route method is called like:

<a
    :href="
        route('catalog.pdp', {
            catalog_uri: listing.catalog_uri,
        })
    "
    class="btn-black-small px-6 py-2 text-sm"
    >Link to catalog</a
>
```


### Ziggy configuration

```js
{
    "url": "https:\/\/example.com",
    "port": null,
    "defaults": {},
    "routes": {
        "catalog.pdp": {
            "uri": "{catalog_uri}",
            "methods": ["GET", "HEAD"],
            "wheres": {
                "catalog_uri": "^\/p\/.+$"
            },
            "domain": "example.com",
            "parameters": ["catalog_uri"]
        }
    }
}
```


### Route definition

```php
Route::get('{catalog_uri}', fn() => view('pdp'))
    ->where('catalog_uri', '^/p/.+$')
    ->name('catalog.pdp');
```


Pull request description:
Fix double slashes with domain params
This PR fixes a bug where it was possible for a URL generated by Ziggy to have a double slash between the host and path if a) the route has a domain parameter, _and_ b) the route path begins immediately with a route parameter (i.e. the parameter is at the root of the domain), _and_ c) the route parameter value passed to the `route()` helper starts with a slash.

Note that we are intentionally leaving the double slash in the generated URL if it occurs later in the path, e.g. `/catalog//p/test`, because this is what Laravel does.

Investigating this issue highlighted some shortcomings in how we replace parameters, which I noted in comments, and uncovered an unrelated and more serious bug that I'll open an issue for shortly.

Closes #751.

Patch:
diff --git a/src/js/Route.js b/src/js/Route.js
index 3a0ba93..b3b4453 100644
--- a/src/js/Route.js
+++ b/src/js/Route.js
@@ -116,6 +116,8 @@ export default class Route {
 
         if (!segments.length) return this.template;
 
+        // This should probably be refactored to build the host and path separately (not the entire URL at once)
+        // because that's how Laravel does it internally and it's more precise and less error-prone
         return this.template
             .replace(/{([^}?]+)(\??)}/g, (_, segment, optional) => {
                 // If the parameter is missing but is not optional, throw an error
@@ -132,7 +134,7 @@ export default class Route {
                         ).test(params[segment] ?? '')
                     ) {
                         throw new Error(
-                            `Ziggy error: '${segment}' parameter does not match required format '${this.wheres[segment]}' for route '${this.name}'.`,
+                            `Ziggy error: '${segment}' parameter '${params[segment]}' does not match required format '${this.wheres[segment]}' for route '${this.name}'.`,
                         );
                     }
                 }
@@ -142,7 +144,7 @@ export default class Route {
                     .replace(/%25/g, '%')
                     .replace(/\$/g, '%24');
             })
-            .replace(`${this.origin}//`, `${this.origin}/`)
+            .replace(this.config.absolute ? /(\.[^/]+?)(\/\/)/ : /(^)(\/\/)/, '$1/')
             .replace(/\/+$/, '');
     }
 }


Test patch:
diff --git a/tests/js/route.test.js b/tests/js/route.test.js
index f02e175..fd3ab2f 100644
--- a/tests/js/route.test.js
+++ b/tests/js/route.test.js
@@ -209,6 +209,58 @@ const defaultZiggy = {
                 slug: '.+',
             },
         },
+        catalog: {
+            uri: '{catalog_uri}',
+            methods: ['GET', 'HEAD'],
+            wheres: {
+                catalog_uri: '^/p/.+$',
+            },
+            parameters: ['catalog_uri'],
+        },
+        'catalog-path': {
+            uri: 'catalog/{catalog_uri}',
+            methods: ['GET', 'HEAD'],
+            wheres: {
+                catalog_uri: '^/p/.+$',
+            },
+            parameters: ['catalog_uri'],
+        },
+        'catalog-domain': {
+            uri: '{catalog_uri}',
+            methods: ['GET', 'HEAD'],
+            domain: 'catalog.ziggy.dev',
+            wheres: {
+                catalog_uri: '^/p/.+$',
+            },
+            parameters: ['catalog_uri'],
+        },
+        'catalog-domain-path': {
+            uri: 'catalog/{catalog_uri}',
+            methods: ['GET', 'HEAD'],
+            domain: 'catalog.ziggy.dev',
+            wheres: {
+                catalog_uri: '^/p/.+$',
+            },
+            parameters: ['catalog_uri'],
+        },
+        'catalog-domain-param': {
+            uri: '{catalog_uri}',
+            methods: ['GET', 'HEAD'],
+            domain: '{storefront}.ziggy.dev',
+            wheres: {
+                catalog_uri: '^/p/.+$',
+            },
+            parameters: ['storefront', 'catalog_uri'],
+        },
+        'catalog-domain-param-path': {
+            uri: 'catalog/{catalog_uri}',
+            methods: ['GET', 'HEAD'],
+            domain: '{storefront}.ziggy.dev',
+            wheres: {
+                catalog_uri: '^/p/.+$',
+            },
+            parameters: ['storefront', 'catalog_uri'],
+        },
     },
 };
 
@@ -683,7 +735,7 @@ describe('route()', () => {
             'https://ziggy.dev/where/download/file.html',
         );
         expect(() => route('pages.optionalExtensionWhere', { extension: '.pdf' })).toThrow(
-            /'extension' parameter does not match required format/,
+            /'extension' parameter '\.pdf' does not match required format/,
         );
     });
 
@@ -692,10 +744,10 @@ describe('route()', () => {
             'https://ziggy.dev/where/strict-download/file.html',
         );
         expect(() => route('pages.requiredExtensionWhere', 'x')).toThrow(
-            /'extension' parameter does not match required format/,
+            /'extension' parameter 'x' does not match required format/,
         );
         expect(() => route('pages.requiredExtensionWhere', { extension: '.pdf' })).toThrow(
-            /'extension' parameter does not match required format/,
+            /'extension' parameter '\.pdf' does not match required format/,
         );
     });
 
@@ -721,6 +773,24 @@ describe('route()', () => {
         );
     });
 
+    // https://github.com/tighten/ziggy/issues/751
+    test('strip leading slash in first route param at root domain with domain parameter', () => {
+        expect(route('catalog', '/p/test')).toBe('https://ziggy.dev/p/test');
+        expect(route('catalog-path', '/p/test')).toBe('https://ziggy.dev/catalog//p/test');
+        expect(route('catalog-domain', '/p/test')).toBe('https://catalog.ziggy.dev/p/test');
+        expect(route('catalog-domain-path', '/p/test')).toBe(
+            'https://catalog.ziggy.dev/catalog//p/test',
+        );
+        expect(route('catalog-domain-param', ['me', '/p/test'])).toBe(
+            'https://me.ziggy.dev/p/test',
+        );
+        expect(route('catalog-domain-param', ['/p/test'], false)).toBe('/p/test');
+        expect(route('catalog-domain-param-path', ['me', '/p/test'])).toBe(
+            'https://me.ziggy.dev/catalog//p/test',
+        );
+        expect(route('catalog-domain-param-path', ['/p/test'], false)).toBe('/catalog//p/test');
+    });
+
     test('skip encoding some characters in route parameters', () => {
         // Laravel doesn't encode these characters in route parameters: / @ : ; , = + ! * | ? & # %
         expect(route('pages', 'a/b')).toBe('https://ziggy.dev/a/b');