@10up/wp-vite-plugins 1.0.0-alpha.3 → 1.0.0-alpha.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,133 @@
1
+ <?php
2
+ /**
3
+ * Pages class.
4
+ *
5
+ * @package TenUp\WpBoot
6
+ */
7
+
8
+ declare( strict_types = 1 );
9
+
10
+ namespace TenUp\WpBoot;
11
+
12
+ use Closure;
13
+ use InvalidArgumentException;
14
+
15
+ /**
16
+ * The `@wordpress/boot` admin pages one build produced, and where its
17
+ * output lives.
18
+ *
19
+ * A port of the PHP `@wordpress/build` generates for its pages, the original
20
+ * implementation (Gutenberg's packages/wp-build/templates; see the README's
21
+ * "Origin"). Where wp-build writes one file per page and keeps routes in
22
+ * globals, the build here emits a `build.php` that calls register() with the
23
+ * pages as data; each page gets a Page per mode it was built for
24
+ * ('full-page' and/or 'wp-admin').
25
+ *
26
+ * @link https://github.com/WordPress/gutenberg/tree/trunk/packages/wp-build
27
+ *
28
+ * @phpstan-type Route array{name: string, path: string, dir: string, has_content: bool, has_route: bool}
29
+ * @phpstan-type Menu array{title: Closure(): string, parent?: string|false, capability?: string, position?: int, icon?: string}
30
+ * @phpstan-type PageConfig array{slug: string, capability: string, modes: list<string>, init_modules: list<string>, rest_preload?: list<string|array{string, string}>, menu?: Menu, routes: list<Route>}
31
+ */
32
+ final class Pages {
33
+
34
+ /**
35
+ * Every registered page, by handle (`<slug>` or `<slug>-wp-admin`).
36
+ *
37
+ * @var array<string, Page>
38
+ */
39
+ private static array $pages = array();
40
+
41
+ /**
42
+ * Memoized result of `$build_url`.
43
+ *
44
+ * @var string|null
45
+ */
46
+ private ?string $url = null;
47
+
48
+ /**
49
+ * Describe one build's output.
50
+ *
51
+ * @param string $build_dir Absolute path of the build directory.
52
+ * @param Closure $build_url Returns the build directory's URL. Called lazily, so
53
+ * filters added after load still apply.
54
+ * @param string $prefix Project prefix, for hook names.
55
+ * @param string $handle_prefix Script Module id prefix for route modules.
56
+ *
57
+ * @phpstan-param Closure(): string $build_url
58
+ */
59
+ public function __construct(
60
+ private readonly string $build_dir,
61
+ private readonly Closure $build_url,
62
+ public readonly string $prefix,
63
+ public readonly string $handle_prefix,
64
+ ) {}
65
+
66
+ /**
67
+ * Register a build's pages and hook them into WordPress.
68
+ *
69
+ * When a page is built for both modes the wp-admin one owns the
70
+ * generated menu entry.
71
+ *
72
+ * @param string $build_dir Absolute path of the build directory.
73
+ * @param Closure $build_url Returns the build directory's URL.
74
+ * @param string $prefix Project prefix, for hook names.
75
+ * @param string $handle_prefix Script Module id prefix for route modules.
76
+ * @param array $pages The pages, as the build wrote them.
77
+ * @return void
78
+ *
79
+ * @phpstan-param Closure(): string $build_url
80
+ * @phpstan-param list<PageConfig> $pages
81
+ */
82
+ public static function register( string $build_dir, Closure $build_url, string $prefix, string $handle_prefix, array $pages ): void {
83
+ $build = new self( $build_dir, $build_url, $prefix, $handle_prefix );
84
+
85
+ foreach ( $pages as $config ) {
86
+ foreach ( $config['modes'] as $mode ) {
87
+ $page = new Page( $build, $config, $mode );
88
+ $owns_menu = isset( $config['menu'] ) && ( Page::WP_ADMIN === $mode || ! in_array( Page::WP_ADMIN, $config['modes'], true ) );
89
+
90
+ $page->register_hooks( $owns_menu );
91
+ self::$pages[ $page->handle ] = $page;
92
+ }
93
+ }
94
+ }
95
+
96
+ /**
97
+ * A registered page, for the generated `<prefix>_register_<slug>_route()`
98
+ * style functions.
99
+ *
100
+ * @param string $handle Page handle: `<slug>` or `<slug>-wp-admin`.
101
+ * @return Page
102
+ * @throws InvalidArgumentException When no page has that handle.
103
+ */
104
+ public static function get( string $handle ): Page {
105
+ if ( ! isset( self::$pages[ $handle ] ) ) {
106
+ throw new InvalidArgumentException( esc_html( "No admin page is registered as \"{$handle}\"." ) );
107
+ }
108
+
109
+ return self::$pages[ $handle ];
110
+ }
111
+
112
+ /**
113
+ * Absolute path of a file in the build directory.
114
+ *
115
+ * @param string $path Build-relative path.
116
+ * @return string
117
+ */
118
+ public function path( string $path ): string {
119
+ return $this->build_dir . '/' . $path;
120
+ }
121
+
122
+ /**
123
+ * URL of a file in the build directory.
124
+ *
125
+ * @param string $path Build-relative path.
126
+ * @return string
127
+ */
128
+ public function url( string $path ): string {
129
+ $this->url ??= ( $this->build_url )();
130
+
131
+ return $this->url . $path;
132
+ }
133
+ }
@@ -0,0 +1,507 @@
1
+ <?php
2
+ /**
3
+ * Bridge class.
4
+ *
5
+ * @package TenUp\WpViteDevServer
6
+ */
7
+
8
+ declare( strict_types = 1 );
9
+
10
+ namespace TenUp\WpViteDevServer;
11
+
12
+ use WP_HTML_Tag_Processor;
13
+
14
+ /**
15
+ * Repoints WordPress's enqueues at a running Vite dev server.
16
+ *
17
+ * WordPress prints the URLs a script, stylesheet or Script Module was
18
+ * registered with. While a dev server is up, every one of those that points
19
+ * into this build's dist directory is mapped back to its source file through
20
+ * the `source` key of the `.asset.php` sidecar next to it, and served by Vite
21
+ * instead:
22
+ *
23
+ * - Classic scripts: `script_loader_src` for the URL, `script_loader_tag`
24
+ * to print them `type="module"`. Filters rather than a registry walk, so a
25
+ * script registered while printing is caught too.
26
+ * - Script Modules: `script_module_loader_src` (WP 6.5+), the single choke
27
+ * point for the module tag, its modulepreload link and the import map.
28
+ * - Stylesheets: dequeued and replaced with a module script, because Vite
29
+ * serves CSS through JavaScript in dev (that is how it hot-swaps them).
30
+ * `style_loader_src` rewrites any it missed to Vite's `?direct` CSS.
31
+ *
32
+ * It also enqueues `@vite/client` ahead of everything it repoints, and the
33
+ * React Fast Refresh preamble when the hot file lists `vite:react-refresh`.
34
+ */
35
+ final class Bridge {
36
+
37
+ /** The dev-server plugin whose presence turns on the React refresh preamble. */
38
+ private const REACT_REFRESH = 'vite:react-refresh';
39
+
40
+ /**
41
+ * Script handle of Vite's HMR client: `<prefix>-vite-client`.
42
+ *
43
+ * @var string
44
+ */
45
+ private readonly string $client;
46
+
47
+ /**
48
+ * URL of the dist directory, or '' when WordPress can't build one. Null until computed.
49
+ *
50
+ * @var string|null
51
+ */
52
+ private ?string $dist_url = null;
53
+
54
+ /**
55
+ * Source path per asset URL already looked up (null: not ours).
56
+ *
57
+ * @var array<string, string|null>
58
+ */
59
+ private array $sources = array();
60
+
61
+ /**
62
+ * Script handles that have to print as `type="module"`.
63
+ *
64
+ * @var array<string, true>
65
+ */
66
+ private array $modules = array();
67
+
68
+ /**
69
+ * Whether the React refresh preamble has been attached.
70
+ *
71
+ * @var bool
72
+ */
73
+ private bool $preamble_attached = false;
74
+
75
+ /**
76
+ * Create a bridge for one dist directory.
77
+ *
78
+ * @param string $dist_dir Absolute path of the dist directory.
79
+ * @param HotFile $hot The running dev server.
80
+ * @param string $prefix Project prefix; namespaces the client handle, constant and filter.
81
+ */
82
+ public function __construct(
83
+ private readonly string $dist_dir,
84
+ private readonly HotFile $hot,
85
+ private readonly string $prefix,
86
+ ) {
87
+ $this->client = $prefix . '-vite-client';
88
+ }
89
+
90
+ /**
91
+ * Start bridging a dist directory when a dev server is running for it.
92
+ *
93
+ * With no hot file this is a single `file_exists()` and no hooks.
94
+ *
95
+ * @param string $dist_dir Absolute path of the dist directory.
96
+ * @param string $hot_file Hot file name, relative to `$dist_dir`.
97
+ * @param string $prefix Project prefix.
98
+ * @return self|null The registered bridge, or null when no dev server is running.
99
+ */
100
+ public static function boot( string $dist_dir, string $hot_file, string $prefix ): ?self {
101
+ $hot = HotFile::read( $dist_dir . '/' . $hot_file );
102
+
103
+ if ( null === $hot ) {
104
+ return null;
105
+ }
106
+
107
+ $bridge = new self( $dist_dir, $hot, $prefix );
108
+
109
+ if ( $bridge->should_verify_dev_server() && ! $bridge->dev_server_responds() ) {
110
+ return null;
111
+ }
112
+
113
+ $bridge->register_hooks();
114
+
115
+ return $bridge;
116
+ }
117
+
118
+ /**
119
+ * Hook the bridge into WordPress's enqueue pipeline.
120
+ *
121
+ * @return void
122
+ */
123
+ private function register_hooks(): void {
124
+ foreach ( array( 'wp_enqueue_scripts', 'admin_enqueue_scripts' ) as $hook ) {
125
+ add_action( $hook, array( $this, 'enqueue_client' ), -1000 );
126
+ add_action( $hook, array( $this, 'prepare' ), PHP_INT_MAX );
127
+ }
128
+
129
+ add_filter( 'script_loader_src', array( $this, 'script_src' ), 10, 2 );
130
+ add_filter( 'script_loader_tag', array( $this, 'script_tag' ), 10, 2 );
131
+ add_filter( 'script_module_loader_src', array( $this, 'script_module_src' ) );
132
+ add_filter( 'style_loader_src', array( $this, 'style_src' ) );
133
+ }
134
+
135
+ /**
136
+ * Whether to probe the dev server before trusting the hot file.
137
+ *
138
+ * Off by default. A `kill -9`'d dev server leaves its hot file behind,
139
+ * pointing at a socket nothing answers on; catching that costs an HTTP
140
+ * request on every request, so it is opt-in through the
141
+ * `<PREFIX>_WP_VITE_CHECK_DEV_SERVER` constant or the
142
+ * `<prefix>_wp_vite_check_dev_server` filter.
143
+ *
144
+ * @return bool
145
+ */
146
+ private function should_verify_dev_server(): bool {
147
+ $prefix = str_replace( '-', '_', $this->prefix );
148
+ $constant = strtoupper( $prefix ) . '_WP_VITE_CHECK_DEV_SERVER';
149
+ $default = defined( $constant ) && (bool) constant( $constant );
150
+
151
+ /**
152
+ * Filters whether the dev-server bridge checks the server is reachable
153
+ * before trusting the hot file.
154
+ *
155
+ * @param bool $verify Default from the `<PREFIX>_WP_VITE_CHECK_DEV_SERVER` constant, else false.
156
+ */
157
+ return (bool) apply_filters( $prefix . '_wp_vite_check_dev_server', $default );
158
+ }
159
+
160
+ /**
161
+ * Whether `@vite/client` answers within 200ms.
162
+ *
163
+ * @return bool
164
+ */
165
+ private function dev_server_responds(): bool {
166
+ $response = wp_remote_head(
167
+ $this->hot->url( '@vite/client' ),
168
+ array( 'timeout' => 0.2 )
169
+ );
170
+
171
+ $status = is_wp_error( $response ) ? 0 : (int) wp_remote_retrieve_response_code( $response );
172
+
173
+ return $status >= 200 && $status < 400;
174
+ }
175
+
176
+ /**
177
+ * Register and enqueue Vite's HMR client before anything else.
178
+ *
179
+ * It opens the websocket, applies hot updates and renders the error
180
+ * overlay.
181
+ *
182
+ * @return void
183
+ */
184
+ public function enqueue_client(): void {
185
+ wp_register_script( $this->client, $this->hot->url( '@vite/client' ), array(), null, false ); // phpcs:ignore WordPress.WP.EnqueuedResourceParameters.MissingVersion
186
+ wp_enqueue_script( $this->client );
187
+ $this->modules[ $this->client ] = true;
188
+
189
+ $this->enqueue_react_refresh_preamble();
190
+ }
191
+
192
+ /**
193
+ * Attach the React Fast Refresh preamble to the client handle, when the
194
+ * dev server runs `@vitejs/plugin-react`.
195
+ *
196
+ * The preamble must run before any component module evaluates. Attaching
197
+ * it to the client, which every repointed classic script depends on and
198
+ * which prints before any Script Module, gets that ordering for free.
199
+ *
200
+ * @return void
201
+ */
202
+ private function enqueue_react_refresh_preamble(): void {
203
+ if ( $this->preamble_attached || ! $this->hot->has_plugin( self::REACT_REFRESH ) ) {
204
+ return;
205
+ }
206
+
207
+ $this->preamble_attached = true;
208
+
209
+ $preamble = sprintf(
210
+ 'import RefreshRuntime from "%1$s"
211
+ RefreshRuntime.injectIntoGlobalHook(window)
212
+ window.$RefreshReg$ = () => {}
213
+ window.$RefreshSig$ = () => (type) => type
214
+ window.__vite_plugin_react_preamble_installed__ = true',
215
+ esc_js( $this->hot->url( '@react-refresh' ) )
216
+ );
217
+
218
+ wp_add_inline_script( $this->client, $preamble, 'after' );
219
+ add_filter( 'wp_inline_script_attributes', array( $this, 'react_refresh_inline_attributes' ) );
220
+ }
221
+
222
+ /**
223
+ * Print the preamble `type="module"`: it is a top-level `import`.
224
+ *
225
+ * Matches the inline block's own `<handle>-js-after` id, so other inline
226
+ * scripts on the client handle stay classic.
227
+ *
228
+ * @param mixed $attributes Attributes of the inline script about to print.
229
+ * @return mixed
230
+ */
231
+ public function react_refresh_inline_attributes( mixed $attributes ): mixed {
232
+ if ( is_array( $attributes ) && isset( $attributes['id'] ) && $this->client . '-js-after' === $attributes['id'] ) {
233
+ $attributes['type'] = 'module';
234
+ }
235
+
236
+ return $attributes;
237
+ }
238
+
239
+ /**
240
+ * The pass that needs the queues still mutable: make the client a
241
+ * dependency of every script about to be repointed, and swap this
242
+ * directory's queued stylesheets for the JS modules Vite serves them as.
243
+ *
244
+ * Runs last on the enqueue hooks.
245
+ *
246
+ * @return void
247
+ */
248
+ public function prepare(): void {
249
+ $claimed = array();
250
+ $has_client = wp_script_is( $this->client, 'registered' );
251
+
252
+ foreach ( wp_scripts()->registered as $handle => $script ) {
253
+ if ( $this->client === $handle || ! is_string( $script->src ) || '' === $script->src ) {
254
+ continue;
255
+ }
256
+
257
+ $source = $this->source( $script->src );
258
+
259
+ if ( null === $source ) {
260
+ continue;
261
+ }
262
+
263
+ $claimed[ $source ] = true;
264
+
265
+ // A missing dependency makes WordPress drop the script, so only
266
+ // depend on a client that is there.
267
+ if ( $has_client && ! in_array( $this->client, $script->deps, true ) ) {
268
+ $script->deps[] = $this->client;
269
+ }
270
+ }
271
+
272
+ $styles = wp_styles();
273
+
274
+ foreach ( $styles->queue as $handle ) {
275
+ $src = isset( $styles->registered[ $handle ] ) ? $styles->registered[ $handle ]->src : null;
276
+
277
+ if ( ! is_string( $src ) || '' === $src ) {
278
+ continue;
279
+ }
280
+
281
+ $source = $this->source( $src );
282
+
283
+ if ( null === $source ) {
284
+ continue;
285
+ }
286
+
287
+ wp_dequeue_style( $handle );
288
+
289
+ // An entry that imports its own CSS already carries it in dev.
290
+ if ( isset( $claimed[ $source ] ) ) {
291
+ continue;
292
+ }
293
+
294
+ $claimed[ $source ] = true;
295
+ $shim = $handle . '-vite-dev';
296
+
297
+ wp_register_script( $shim, $this->hot->url( $source ), $has_client ? array( $this->client ) : array(), null, false ); // phpcs:ignore WordPress.WP.EnqueuedResourceParameters.MissingVersion
298
+ wp_enqueue_script( $shim );
299
+ $this->modules[ $shim ] = true;
300
+ }
301
+ }
302
+
303
+ /**
304
+ * Repoint a classic script.
305
+ *
306
+ * @param mixed $src Script URL.
307
+ * @param string $handle Script handle.
308
+ * @return mixed The dev-server URL, or `$src` unchanged.
309
+ */
310
+ public function script_src( mixed $src, string $handle ): mixed {
311
+ $source = $this->source( $src );
312
+
313
+ if ( null === $source ) {
314
+ return $src;
315
+ }
316
+
317
+ // script_loader_src always runs before script_loader_tag for the same
318
+ // handle, so marking it here is enough.
319
+ $this->modules[ $handle ] = true;
320
+
321
+ return $this->hot->url( $source );
322
+ }
323
+
324
+ /**
325
+ * Print the scripts this bridge repointed as `type="module"`.
326
+ *
327
+ * @param mixed $tag Script tag HTML.
328
+ * @param string $handle Script handle.
329
+ * @return mixed
330
+ */
331
+ public function script_tag( mixed $tag, string $handle ): mixed {
332
+ if ( ! is_string( $tag ) || ! isset( $this->modules[ $handle ] ) ) {
333
+ return $tag;
334
+ }
335
+
336
+ $processor = new WP_HTML_Tag_Processor( $tag );
337
+
338
+ while ( $processor->next_tag( array( 'tag_name' => 'script' ) ) ) {
339
+ // Inline blocks attached to the same handle stay classic.
340
+ if ( null !== $processor->get_attribute( 'src' ) ) {
341
+ $processor->set_attribute( 'type', 'module' );
342
+ }
343
+ }
344
+
345
+ return $processor->get_updated_html();
346
+ }
347
+
348
+ /**
349
+ * Repoint a Script Module.
350
+ *
351
+ * @param mixed $src Script Module URL.
352
+ * @return mixed
353
+ */
354
+ public function script_module_src( mixed $src ): mixed {
355
+ $source = $this->source( $src );
356
+
357
+ return null === $source ? $src : $this->hot->url( $source );
358
+ }
359
+
360
+ /**
361
+ * Repoint a stylesheet prepare() never saw (registered later, or pulled
362
+ * in as another stylesheet's dependency) at Vite's plain-CSS `?direct`
363
+ * URL: correct styles, no hot reload.
364
+ *
365
+ * @param mixed $src Stylesheet URL.
366
+ * @return mixed
367
+ */
368
+ public function style_src( mixed $src ): mixed {
369
+ $source = $this->source( $src );
370
+
371
+ return null === $source ? $src : $this->hot->url( $source ) . '?direct';
372
+ }
373
+
374
+ /**
375
+ * Map an asset URL in the dist directory to the source file Vite serves.
376
+ *
377
+ * Strips the query and the dist URL, swaps the extension for
378
+ * `.asset.php` and reads that sidecar's 'source' key.
379
+ *
380
+ * @param mixed $url Asset URL as WordPress is about to print it.
381
+ * @return string|null Project-relative source path, or null when the URL is
382
+ * not in the dist directory or has no sidecar.
383
+ */
384
+ private function source( mixed $url ): ?string {
385
+ if ( ! is_string( $url ) || '' === $url ) {
386
+ return null;
387
+ }
388
+
389
+ $dist_url = $this->dist_url();
390
+
391
+ if ( '' === $dist_url ) {
392
+ return null;
393
+ }
394
+
395
+ // The sidecar lives at the unversioned path.
396
+ $query = strpos( $url, '?' );
397
+ if ( false !== $query ) {
398
+ $url = substr( $url, 0, $query );
399
+ }
400
+
401
+ if ( ! array_key_exists( $url, $this->sources ) ) {
402
+ $this->sources[ $url ] = $this->lookup_source( $url, $dist_url );
403
+ }
404
+
405
+ return $this->sources[ $url ];
406
+ }
407
+
408
+ /**
409
+ * The uncached half of source().
410
+ *
411
+ * @param string $url Asset URL without its query.
412
+ * @param string $dist_url URL of the dist directory.
413
+ * @return string|null
414
+ */
415
+ private function lookup_source( string $url, string $dist_url ): ?string {
416
+ $relative = null;
417
+
418
+ // Compare without the scheme (an http/https mismatch is routine), then
419
+ // by path alone, for root-relative and CDN-fronted srcs.
420
+ $candidates = array(
421
+ array( self::strip_scheme( $dist_url ), self::strip_scheme( $url ) ),
422
+ array( self::strip_host( $dist_url ), self::strip_host( $url ) ),
423
+ );
424
+
425
+ foreach ( $candidates as list( $prefix, $candidate ) ) {
426
+ if ( '' !== $prefix && str_starts_with( $candidate, $prefix ) ) {
427
+ $relative = substr( $candidate, strlen( $prefix ) );
428
+ break;
429
+ }
430
+ }
431
+
432
+ // The path came out of a URL and reaches a require(): only the shape a
433
+ // build emits gets through.
434
+ if ( null === $relative || ! preg_match( '#^[A-Za-z0-9_][A-Za-z0-9_./-]*$#', $relative ) || str_contains( $relative, '..' ) ) {
435
+ return null;
436
+ }
437
+
438
+ $asset_file = $this->dist_dir . '/' . preg_replace( '/\.(js|mjs|css)$/', '', $relative ) . '.asset.php';
439
+
440
+ if ( ! file_exists( $asset_file ) ) {
441
+ return null;
442
+ }
443
+
444
+ $asset = require $asset_file;
445
+
446
+ return is_array( $asset ) && ! empty( $asset['source'] ) && is_string( $asset['source'] ) ? $asset['source'] : null;
447
+ }
448
+
449
+ /**
450
+ * A URL without its scheme: `//host/path`.
451
+ *
452
+ * @param string $url URL.
453
+ * @return string
454
+ */
455
+ private static function strip_scheme( string $url ): string {
456
+ return (string) preg_replace( '#^[a-z][a-z0-9+.-]*:#i', '', $url );
457
+ }
458
+
459
+ /**
460
+ * A URL without its scheme and host: `/path`.
461
+ *
462
+ * @param string $url URL.
463
+ * @return string
464
+ */
465
+ private static function strip_host( string $url ): string {
466
+ return (string) preg_replace( '#^([a-z][a-z0-9+.-]*:)?//[^/]*#i', '', $url );
467
+ }
468
+
469
+ /**
470
+ * URL of the dist directory, derived from its path.
471
+ *
472
+ * Tried most-specific first: a theme under a custom theme root, or a
473
+ * directory reached through a symlink, is only spelled right by its own
474
+ * helper.
475
+ *
476
+ * @return string Trailing-slashed URL, or '' when the directory is outside
477
+ * every location WordPress can build a URL for.
478
+ */
479
+ private function dist_url(): string {
480
+ if ( null !== $this->dist_url ) {
481
+ return $this->dist_url;
482
+ }
483
+
484
+ $this->dist_url = '';
485
+ $dir = rtrim( str_replace( '\\', '/', $this->dist_dir ), '/' );
486
+ $roots = array(
487
+ array( get_stylesheet_directory(), get_stylesheet_directory_uri() ),
488
+ array( get_template_directory(), get_template_directory_uri() ),
489
+ array( WPMU_PLUGIN_DIR, WPMU_PLUGIN_URL ),
490
+ array( WP_PLUGIN_DIR, WP_PLUGIN_URL ),
491
+ array( WP_CONTENT_DIR, content_url() ),
492
+ );
493
+
494
+ foreach ( $roots as list( $root_dir, $root_url ) ) {
495
+ $root_dir = rtrim( str_replace( '\\', '/', $root_dir ), '/' );
496
+
497
+ if ( '' === $root_dir || '' === $root_url || ! str_starts_with( $dir . '/', $root_dir . '/' ) ) {
498
+ continue;
499
+ }
500
+
501
+ $this->dist_url = rtrim( $root_url, '/' ) . substr( $dir, strlen( $root_dir ) ) . '/';
502
+ break;
503
+ }
504
+
505
+ return $this->dist_url;
506
+ }
507
+ }