diff --git a/plugins/embed-optimizer/tests/test-cases/single-youtube-embed-inside-viewport/expected.html b/plugins/embed-optimizer/tests/test-cases/single-youtube-embed-inside-viewport/expected.html
index 963505b1b1..33c517b381 100644
--- a/plugins/embed-optimizer/tests/test-cases/single-youtube-embed-inside-viewport/expected.html
+++ b/plugins/embed-optimizer/tests/test-cases/single-youtube-embed-inside-viewport/expected.html
@@ -2,10 +2,11 @@
diff --git a/plugins/embed-optimizer/tests/test-cases/single-youtube-embed-outside-viewport/expected.html b/plugins/embed-optimizer/tests/test-cases/single-youtube-embed-outside-viewport/expected.html
index 338c963b18..d567e4c57c 100644
--- a/plugins/embed-optimizer/tests/test-cases/single-youtube-embed-outside-viewport/expected.html
+++ b/plugins/embed-optimizer/tests/test-cases/single-youtube-embed-outside-viewport/expected.html
@@ -2,10 +2,11 @@
diff --git a/plugins/image-prioritizer/tests/test-cases/different-lcp-elements-for-non-consecutive-viewport-groups-with-missing-data-for-middle-group/set-up.php b/plugins/image-prioritizer/tests/test-cases/different-lcp-elements-for-non-consecutive-viewport-groups-with-missing-data-for-middle-group/set-up.php
index 2063a0ff4a..6a392ee518 100644
--- a/plugins/image-prioritizer/tests/test-cases/different-lcp-elements-for-non-consecutive-viewport-groups-with-missing-data-for-middle-group/set-up.php
+++ b/plugins/image-prioritizer/tests/test-cases/different-lcp-elements-for-non-consecutive-viewport-groups-with-missing-data-for-middle-group/set-up.php
@@ -4,7 +4,7 @@
od_get_url_metrics_slug( od_get_normalized_query_vars() ),
$test_case->get_sample_url_metric(
array(
- 'viewport_width' => 400,
+ 'viewport_width' => 300,
'elements' => array(
array(
'isLCP' => true,
diff --git a/plugins/image-prioritizer/tests/test-cases/fetch-priority-high-already-on-common-lcp-image-with-fully-populated-sample-data/expected.html b/plugins/image-prioritizer/tests/test-cases/fetch-priority-high-already-on-common-lcp-image-with-fully-populated-sample-data/expected.html
index 80af67ffb5..1c071f2377 100644
--- a/plugins/image-prioritizer/tests/test-cases/fetch-priority-high-already-on-common-lcp-image-with-fully-populated-sample-data/expected.html
+++ b/plugins/image-prioritizer/tests/test-cases/fetch-priority-high-already-on-common-lcp-image-with-fully-populated-sample-data/expected.html
@@ -2,7 +2,7 @@
...
-
+
diff --git a/plugins/image-prioritizer/tests/test-cases/images-located-above-or-along-initial-viewport/expected.html b/plugins/image-prioritizer/tests/test-cases/images-located-above-or-along-initial-viewport/expected.html
index 8b4aabd867..2f74c79c58 100644
--- a/plugins/image-prioritizer/tests/test-cases/images-located-above-or-along-initial-viewport/expected.html
+++ b/plugins/image-prioritizer/tests/test-cases/images-located-above-or-along-initial-viewport/expected.html
@@ -2,7 +2,7 @@
...
-
+
diff --git a/plugins/image-prioritizer/tests/test-cases/no-lcp-image-or-background-image-outside-viewport-with-populated-url-metrics/expected.html b/plugins/image-prioritizer/tests/test-cases/no-lcp-image-or-background-image-outside-viewport-with-populated-url-metrics/expected.html
index ba87599e0e..da5669b295 100644
--- a/plugins/image-prioritizer/tests/test-cases/no-lcp-image-or-background-image-outside-viewport-with-populated-url-metrics/expected.html
+++ b/plugins/image-prioritizer/tests/test-cases/no-lcp-image-or-background-image-outside-viewport-with-populated-url-metrics/expected.html
@@ -2,7 +2,7 @@
...
-
+
diff --git a/plugins/image-prioritizer/tests/test-cases/no-url-metrics-but-server-side-heuristics-added-fetchpriority-high/expected.html b/plugins/image-prioritizer/tests/test-cases/no-url-metrics-but-server-side-heuristics-added-fetchpriority-high/expected.html
index dcc0d86109..9622c21694 100644
--- a/plugins/image-prioritizer/tests/test-cases/no-url-metrics-but-server-side-heuristics-added-fetchpriority-high/expected.html
+++ b/plugins/image-prioritizer/tests/test-cases/no-url-metrics-but-server-side-heuristics-added-fetchpriority-high/expected.html
@@ -2,7 +2,7 @@
...
-
+
diff --git a/plugins/image-prioritizer/tests/test-cases/no-url-metrics-for-image-without-src/expected.html b/plugins/image-prioritizer/tests/test-cases/no-url-metrics-for-image-without-src/expected.html
index ee4c8f9560..207f0d5a80 100644
--- a/plugins/image-prioritizer/tests/test-cases/no-url-metrics-for-image-without-src/expected.html
+++ b/plugins/image-prioritizer/tests/test-cases/no-url-metrics-for-image-without-src/expected.html
@@ -2,7 +2,7 @@
...
-
+
diff --git a/plugins/image-prioritizer/tests/test-cases/no-url-metrics-with-data-url-background-image/expected.html b/plugins/image-prioritizer/tests/test-cases/no-url-metrics-with-data-url-background-image/expected.html
index 5aabb64638..b4409cf966 100644
--- a/plugins/image-prioritizer/tests/test-cases/no-url-metrics-with-data-url-background-image/expected.html
+++ b/plugins/image-prioritizer/tests/test-cases/no-url-metrics-with-data-url-background-image/expected.html
@@ -2,7 +2,7 @@
...
-
+
diff --git a/plugins/image-prioritizer/tests/test-cases/no-url-metrics-with-data-url-image/expected.html b/plugins/image-prioritizer/tests/test-cases/no-url-metrics-with-data-url-image/expected.html
index 8247a06bff..a949c34d83 100644
--- a/plugins/image-prioritizer/tests/test-cases/no-url-metrics-with-data-url-image/expected.html
+++ b/plugins/image-prioritizer/tests/test-cases/no-url-metrics-with-data-url-image/expected.html
@@ -2,7 +2,7 @@
...
-
+
diff --git a/plugins/image-prioritizer/tests/test-cases/no-url-metrics-with-non-background-image-style/expected.html b/plugins/image-prioritizer/tests/test-cases/no-url-metrics-with-non-background-image-style/expected.html
index 4592fc39a5..5a7cb9a6f5 100644
--- a/plugins/image-prioritizer/tests/test-cases/no-url-metrics-with-non-background-image-style/expected.html
+++ b/plugins/image-prioritizer/tests/test-cases/no-url-metrics-with-non-background-image-style/expected.html
@@ -2,7 +2,7 @@
...
-
+
diff --git a/plugins/image-prioritizer/tests/test-cases/no-url-metrics/expected.html b/plugins/image-prioritizer/tests/test-cases/no-url-metrics/expected.html
index be90c68224..b11fbde884 100644
--- a/plugins/image-prioritizer/tests/test-cases/no-url-metrics/expected.html
+++ b/plugins/image-prioritizer/tests/test-cases/no-url-metrics/expected.html
@@ -2,7 +2,7 @@
...
-
+
diff --git a/plugins/image-prioritizer/tests/test-cases/preload-links-with-one-half-stale-group/expected.html b/plugins/image-prioritizer/tests/test-cases/preload-links-with-one-half-stale-group/expected.html
index 06c23047a4..a4d9807c7c 100644
--- a/plugins/image-prioritizer/tests/test-cases/preload-links-with-one-half-stale-group/expected.html
+++ b/plugins/image-prioritizer/tests/test-cases/preload-links-with-one-half-stale-group/expected.html
@@ -2,7 +2,7 @@
...
-
+
diff --git a/plugins/image-prioritizer/tests/test-cases/preload-links-with-one-half-stale-group/set-up.php b/plugins/image-prioritizer/tests/test-cases/preload-links-with-one-half-stale-group/set-up.php
index 0ca81bc869..92df2d1d14 100644
--- a/plugins/image-prioritizer/tests/test-cases/preload-links-with-one-half-stale-group/set-up.php
+++ b/plugins/image-prioritizer/tests/test-cases/preload-links-with-one-half-stale-group/set-up.php
@@ -16,7 +16,7 @@ static function ( array $url_metric_data ) use ( $current_etag ): OD_URL_Metric
$url_metrics_data
),
$current_etag,
- array( 480, 600, 782 ),
+ array( 380, 480, 600, 782 ),
3,
WEEK_IN_SECONDS
);
diff --git a/plugins/image-prioritizer/tests/test-cases/preload-links-with-one-half-stale-group/url-metrics.json b/plugins/image-prioritizer/tests/test-cases/preload-links-with-one-half-stale-group/url-metrics.json
index 06a490fed2..dac2c07d0b 100644
--- a/plugins/image-prioritizer/tests/test-cases/preload-links-with-one-half-stale-group/url-metrics.json
+++ b/plugins/image-prioritizer/tests/test-cases/preload-links-with-one-half-stale-group/url-metrics.json
@@ -191,6 +191,198 @@
"uuid": "7dfaa404-bab1-41ff-9e10-a0e8c855d586",
"etag": "f8527651f96776745f88cc49df70b62d"
},
+ {
+ "url": "https://example.net/",
+ "viewport": {
+ "width": 414,
+ "height": 896
+ },
+ "elements": [
+ {
+ "isLCP": true,
+ "isLCPCandidate": true,
+ "xpath": "/HTML/BODY/DIV[@class='wp-site-blocks']/*[2][self::MAIN]/*[2][self::DIV]/*[1][self::UL]/*[1][self::LI]/*[1][self::DIV]/*[1][self::FIGURE]/*[1][self::A]/*[1][self::IMG]",
+ "intersectionRatio": 1,
+ "intersectionRect": {
+ "x": 30,
+ "y": 182.5,
+ "width": 300.83331298828125,
+ "height": 200.546875,
+ "top": 182.5,
+ "right": 330.83331298828125,
+ "bottom": 383.046875,
+ "left": 30
+ },
+ "boundingClientRect": {
+ "x": 30,
+ "y": 182.5,
+ "width": 300.83331298828125,
+ "height": 200.546875,
+ "top": 182.5,
+ "right": 330.83331298828125,
+ "bottom": 383.046875,
+ "left": 30
+ }
+ },
+ {
+ "isLCP": false,
+ "isLCPCandidate": false,
+ "xpath": "/HTML/BODY/DIV[@class='wp-site-blocks']/*[2][self::MAIN]/*[2][self::DIV]/*[1][self::UL]/*[2][self::LI]/*[1][self::DIV]/*[1][self::FIGURE]/*[1][self::A]/*[1][self::IMG]",
+ "intersectionRatio": 0,
+ "intersectionRect": {
+ "x": 0,
+ "y": 0,
+ "width": 0,
+ "height": 0,
+ "top": 0,
+ "right": 0,
+ "bottom": 0,
+ "left": 0
+ },
+ "boundingClientRect": {
+ "x": 30,
+ "y": 899.5572509765625,
+ "width": 300.83331298828125,
+ "height": 200.546875,
+ "top": 899.5572509765625,
+ "right": 330.83331298828125,
+ "bottom": 1100.1041259765625,
+ "left": 30
+ }
+ }
+ ],
+ "timestamp": 1740979411.720624,
+ "uuid": "913270c1-7903-4fa5-8020-537a88b099ce",
+ "etag": "f8527651f96776745f88cc49df70b62d"
+ },
+ {
+ "url": "https://example.net/",
+ "viewport": {
+ "width": 414,
+ "height": 896
+ },
+ "elements": [
+ {
+ "isLCP": true,
+ "isLCPCandidate": true,
+ "xpath": "/HTML/BODY/DIV[@class='wp-site-blocks']/*[2][self::MAIN]/*[2][self::DIV]/*[1][self::UL]/*[1][self::LI]/*[1][self::DIV]/*[1][self::FIGURE]/*[1][self::A]/*[1][self::IMG]",
+ "intersectionRatio": 1,
+ "intersectionRect": {
+ "x": 30,
+ "y": 228.4895782470703,
+ "width": 300.83331298828125,
+ "height": 200.546875,
+ "top": 228.4895782470703,
+ "right": 330.83331298828125,
+ "bottom": 429.0364532470703,
+ "left": 30
+ },
+ "boundingClientRect": {
+ "x": 30,
+ "y": 228.4895782470703,
+ "width": 300.83331298828125,
+ "height": 200.546875,
+ "top": 228.4895782470703,
+ "right": 330.83331298828125,
+ "bottom": 429.0364532470703,
+ "left": 30
+ }
+ },
+ {
+ "isLCP": false,
+ "isLCPCandidate": false,
+ "xpath": "/HTML/BODY/DIV[@class='wp-site-blocks']/*[2][self::MAIN]/*[2][self::DIV]/*[1][self::UL]/*[2][self::LI]/*[1][self::DIV]/*[1][self::FIGURE]/*[1][self::A]/*[1][self::IMG]",
+ "intersectionRatio": 0,
+ "intersectionRect": {
+ "x": 0,
+ "y": 0,
+ "width": 0,
+ "height": 0,
+ "top": 0,
+ "right": 0,
+ "bottom": 0,
+ "left": 0
+ },
+ "boundingClientRect": {
+ "x": 30,
+ "y": 945.546875,
+ "width": 300.83331298828125,
+ "height": 200.546875,
+ "top": 945.546875,
+ "right": 330.83331298828125,
+ "bottom": 1146.09375,
+ "left": 30
+ }
+ }
+ ],
+ "timestamp": 1740979341.748015,
+ "uuid": "1ea8ffa1-0af2-4af1-8a01-0728af72a12f",
+ "etag": "f8527651f96776745f88cc49df70b62d"
+ },
+ {
+ "url": "https://example.net/",
+ "viewport": {
+ "width": 414,
+ "height": 896
+ },
+ "elements": [
+ {
+ "isLCP": true,
+ "isLCPCandidate": true,
+ "xpath": "/HTML/BODY/DIV[@class='wp-site-blocks']/*[2][self::MAIN]/*[2][self::DIV]/*[1][self::UL]/*[1][self::LI]/*[1][self::DIV]/*[1][self::FIGURE]/*[1][self::A]/*[1][self::IMG]",
+ "intersectionRatio": 1,
+ "intersectionRect": {
+ "x": 30,
+ "y": 228.4895782470703,
+ "width": 300.83331298828125,
+ "height": 200.546875,
+ "top": 228.4895782470703,
+ "right": 330.83331298828125,
+ "bottom": 429.0364532470703,
+ "left": 30
+ },
+ "boundingClientRect": {
+ "x": 30,
+ "y": 228.4895782470703,
+ "width": 300.83331298828125,
+ "height": 200.546875,
+ "top": 228.4895782470703,
+ "right": 330.83331298828125,
+ "bottom": 429.0364532470703,
+ "left": 30
+ }
+ },
+ {
+ "isLCP": false,
+ "isLCPCandidate": false,
+ "xpath": "/HTML/BODY/DIV[@class='wp-site-blocks']/*[2][self::MAIN]/*[2][self::DIV]/*[1][self::UL]/*[2][self::LI]/*[1][self::DIV]/*[1][self::FIGURE]/*[1][self::A]/*[1][self::IMG]",
+ "intersectionRatio": 0,
+ "intersectionRect": {
+ "x": 0,
+ "y": 0,
+ "width": 0,
+ "height": 0,
+ "top": 0,
+ "right": 0,
+ "bottom": 0,
+ "left": 0
+ },
+ "boundingClientRect": {
+ "x": 30,
+ "y": 945.546875,
+ "width": 300.83331298828125,
+ "height": 200.546875,
+ "top": 945.546875,
+ "right": 330.83331298828125,
+ "bottom": 1146.09375,
+ "left": 30
+ }
+ }
+ ],
+ "timestamp": 1740979334.440874,
+ "uuid": "7e13830d-48d5-4f1b-81e2-53f6c1f2f50b",
+ "etag": "f8527651f96776745f88cc49df70b62d"
+ },
{
"url": "https://example.net/",
"viewport": {
diff --git a/plugins/image-prioritizer/tests/test-cases/url-metric-only-captured-for-one-breakpoint/expected.html b/plugins/image-prioritizer/tests/test-cases/url-metric-only-captured-for-one-breakpoint/expected.html
index cee55dff40..8221577b1a 100644
--- a/plugins/image-prioritizer/tests/test-cases/url-metric-only-captured-for-one-breakpoint/expected.html
+++ b/plugins/image-prioritizer/tests/test-cases/url-metric-only-captured-for-one-breakpoint/expected.html
@@ -3,7 +3,7 @@
...
-
+
diff --git a/plugins/image-prioritizer/tests/test-cases/url-metric-only-captured-for-one-breakpoint/set-up.php b/plugins/image-prioritizer/tests/test-cases/url-metric-only-captured-for-one-breakpoint/set-up.php
index 6bbf0e6d97..7976525fbd 100644
--- a/plugins/image-prioritizer/tests/test-cases/url-metric-only-captured-for-one-breakpoint/set-up.php
+++ b/plugins/image-prioritizer/tests/test-cases/url-metric-only-captured-for-one-breakpoint/set-up.php
@@ -4,7 +4,7 @@
od_get_url_metrics_slug( od_get_normalized_query_vars() ),
$test_case->get_sample_url_metric(
array(
- 'viewport_width' => 400,
+ 'viewport_width' => 300,
'element' => array(
'isLCP' => true,
'xpath' => '/HTML/BODY/DIV[@id=\'page\']/*[1][self::IMG]',
diff --git a/plugins/optimization-detective/class-od-url-metric.php b/plugins/optimization-detective/class-od-url-metric.php
index 6dc4e9bac6..8496e55614 100644
--- a/plugins/optimization-detective/class-od-url-metric.php
+++ b/plugins/optimization-detective/class-od-url-metric.php
@@ -43,7 +43,8 @@
* url: non-empty-string,
* timestamp: float,
* viewport: ViewportRect,
- * elements: ElementData[]
+ * elements: ElementData[],
+ * source?: 'visitor'|'user'|'synthetic',
* }
* @phpstan-type JSONSchema array{
* type: string|string[],
@@ -310,6 +311,17 @@ public static function get_json_schema(): array {
'additionalProperties' => true,
),
),
+ 'source' => array(
+ 'description' => __( 'The source of the URL Metric.', 'optimization-detective' ),
+ 'type' => 'string',
+ 'required' => false,
+ 'readonly' => true, // Omit from REST API.
+ 'enum' => array(
+ 'visitor',
+ 'user',
+ 'synthetic',
+ ),
+ ),
),
// Additional root properties may be added to the schema via the od_url_metric_schema_root_additional_properties filter.
// Therefore, `additionalProperties` is set to true so that additional properties defined in the extended schema may persist
@@ -526,6 +538,17 @@ function ( array $element ): OD_Element {
return $this->elements;
}
+ /**
+ * Gets the source of the URL Metric.
+ *
+ * @since n.e.x.t
+ *
+ * @return 'visitor'|'user'|'synthetic'|null Source.
+ */
+ public function get_source(): ?string {
+ return $this->data['source'] ?? null;
+ }
+
/**
* Specifies data which should be serialized to JSON.
*
diff --git a/plugins/optimization-detective/detect.js b/plugins/optimization-detective/detect.js
index a599098ebc..38300a01b8 100644
--- a/plugins/optimization-detective/detect.js
+++ b/plugins/optimization-detective/detect.js
@@ -65,6 +65,22 @@ const storageLockTimeSessionKey = 'odStorageLockTime';
*/
const compressionDebounceWaitDuration = 1000;
+/**
+ * Verification token for skipping the storage lock check while in priming mode.
+ *
+ * @see {detect}
+ * @type {?string}
+ */
+let primeModeVerificationToken = null;
+
+/**
+ * Source of the priming mode (i.e. admin-dashboard or priming-cli).
+ *
+ * @see {detect}
+ * @type {?string}
+ */
+let primeModeSource = null;
+
/**
* Checks whether storage is locked.
*
@@ -73,6 +89,10 @@ const compressionDebounceWaitDuration = 1000;
* @return {boolean} Whether storage is locked.
*/
function isStorageLocked( currentTime, storageLockTTL ) {
+ if ( primeModeVerificationToken ) {
+ return false;
+ }
+
if ( storageLockTTL === 0 ) {
return false;
}
@@ -503,6 +523,114 @@ function debounceCompressUrlMetric() {
}, compressionDebounceWaitDuration );
}
+/**
+ * Forces immediate compression of the URL Metric, bypassing debounce and idle callbacks.
+ *
+ * @return {Promise
} A promise that resolves when the compression is complete.
+ */
+async function forceCompressUrlMetric() {
+ if ( ! compressionEnabled ) {
+ return null;
+ }
+ if ( null !== recompressionTimeout ) {
+ clearTimeout( recompressionTimeout );
+ recompressionTimeout = null;
+ }
+ if (
+ null !== idleCallbackHandle &&
+ typeof cancelIdleCallback === 'function'
+ ) {
+ cancelIdleCallback( idleCallbackHandle );
+ idleCallbackHandle = null;
+ }
+
+ try {
+ compressedPayload = await compress( JSON.stringify( urlMetric ) );
+ } catch ( err ) {
+ const { error } = createLogger( false, consoleLogPrefix );
+ error(
+ 'Failed to compress URL Metric falling back to sending uncompressed data:',
+ err
+ );
+ compressionEnabled = false;
+ }
+}
+
+/**
+ * Notifies about the URL Metric request status.
+ *
+ * @param {Object} status - The status details.
+ * @param {boolean} status.success - Indicates if the request succeeded.
+ * @param {string} [status.error] - An error message if the request failed.
+ * @param {?string} source - The source of the priming mode (i.e. admin-dashboard or priming-cli).
+ */
+function notifyStatus( status, source ) {
+ const message = {
+ type: 'OD_PRIME_URL_METRICS_REQUEST_STATUS',
+ success: status.success,
+ ...( status.error && { error: status.error } ),
+ };
+
+ if (
+ 'admin-dashboard' === source &&
+ window.parent &&
+ window.parent !== window
+ ) {
+ window.parent.postMessage( message, '*' );
+ } else if ( 'priming-cli' === source ) {
+ document.dispatchEvent(
+ new CustomEvent( message.type, {
+ detail: { ...status },
+ } )
+ );
+ }
+}
+
+/**
+ * Scrolls to the bottom of the page.
+ *
+ * @return {Promise} A promise that resolves when the scroll is complete.
+ */
+async function scrollToBottomOfPage() {
+ return new Promise( ( resolve ) => {
+ const viewportHeight = window.innerHeight;
+ const maxScrollAttempts = 20;
+ const maxHeightChangeAmount = viewportHeight * 0.5;
+ const maxHeightChangeAttempts = 3;
+
+ let scrollAttempts = 0;
+ let heightChangeAttempts = 0;
+ let lastScrollPosition = 0;
+ let lastHeight = document.documentElement.scrollHeight;
+
+ function scroll() {
+ const newHeight = document.documentElement.scrollHeight;
+ if (
+ window.innerHeight + window.scrollY >= newHeight - 10 ||
+ scrollAttempts >= maxScrollAttempts ||
+ heightChangeAttempts >= maxHeightChangeAttempts
+ ) {
+ resolve();
+ return;
+ }
+
+ lastScrollPosition += viewportHeight;
+ window.scrollTo( { top: lastScrollPosition, behavior: 'smooth' } );
+
+ const heightChange = newHeight - lastHeight;
+ if ( heightChange >= maxHeightChangeAmount ) {
+ lastHeight = newHeight;
+ heightChangeAttempts++;
+ }
+
+ scrollAttempts++;
+ setTimeout( scroll, 300 );
+ }
+
+ scroll();
+ } );
+}
+
/**
* @typedef {{timestamp: number, creationDate: Date}} UrlMetricDebugData
* @typedef {{groups: Array<{url_metrics: Array}>}} CollectionDebugData
@@ -613,6 +741,19 @@ export default async function detect( {
return;
}
+ // Retrieve verification token from the URL hash for priming URL Metrics.
+ // Presence of the token indicates that the URL Metric is being primed
+ // through the Puppeteer script or WordPress admin dashboard.
+ if ( '' !== window.location.hash ) {
+ const searchParams = new URLSearchParams(
+ window.location.hash.slice( 1 )
+ );
+ primeModeVerificationToken = searchParams.get(
+ 'odPrimeModeVerificationToken'
+ );
+ primeModeSource = searchParams.get( 'odPrimeModeSource' );
+ }
+
// Abort if the client already submitted a URL Metric for this URL and viewport group.
const alreadySubmittedSessionStorageKey =
await getAlreadySubmittedSessionStorageKey(
@@ -622,6 +763,7 @@ export default async function detect( {
logger
);
if (
+ ! primeModeVerificationToken &&
null !== alreadySubmittedSessionStorageKey &&
alreadySubmittedSessionStorageKey in sessionStorage
) {
@@ -921,19 +1063,25 @@ export default async function detect( {
debounceCompressUrlMetric();
// Wait for the page to be hidden.
- await new Promise( ( resolve ) => {
- win.addEventListener( 'pagehide', resolve, { once: true } );
- win.addEventListener( 'pageswap', resolve, { once: true } );
- doc.addEventListener(
- 'visibilitychange',
- () => {
- if ( doc.visibilityState === 'hidden' ) {
- // TODO: This will fire even when switching tabs.
- resolve();
- }
- },
- { once: true }
- );
+ await new Promise( async ( resolve ) => {
+ if ( ! primeModeVerificationToken ) {
+ win.addEventListener( 'pagehide', resolve, { once: true } );
+ win.addEventListener( 'pageswap', resolve, { once: true } );
+ doc.addEventListener(
+ 'visibilitychange',
+ () => {
+ if ( doc.visibilityState === 'hidden' ) {
+ // TODO: This will fire even when switching tabs.
+ resolve();
+ }
+ },
+ { once: true }
+ );
+ } else {
+ await scrollToBottomOfPage();
+ await forceCompressUrlMetric();
+ resolve();
+ }
} );
// Only proceed with submitting the URL Metric if the viewport stayed the same size. Changing the viewport size (e.g. due
@@ -1092,6 +1240,12 @@ export default async function detect( {
);
}
url.searchParams.set( 'hmac', urlMetricHMAC );
+ if ( primeModeVerificationToken ) {
+ url.searchParams.set(
+ 'prime_url_metrics_verification_token',
+ primeModeVerificationToken
+ );
+ }
const headers = {
'Content-Type': 'application/json',
@@ -1104,7 +1258,24 @@ export default async function detect( {
method: 'POST',
body: payloadBlob,
headers,
- keepalive: true, // This makes fetch() behave the same as navigator.sendBeacon().
+ keepalive: primeModeVerificationToken ? false : true, // Setting keepalive to true makes fetch() behave the same as navigator.sendBeacon().
} );
- await fetch( request );
+
+ if ( ! primeModeVerificationToken ) {
+ await fetch( request );
+ } else {
+ try {
+ const response = await fetch( request );
+ if ( ! response.ok ) {
+ const errorData = await response.json();
+ throw new Error( errorData.code );
+ }
+ notifyStatus( { success: true }, primeModeSource );
+ } catch ( err ) {
+ notifyStatus(
+ { success: false, error: err.message },
+ primeModeSource
+ );
+ }
+ }
}
diff --git a/plugins/optimization-detective/detection.php b/plugins/optimization-detective/detection.php
index a36decccad..26fbcdbdc1 100644
--- a/plugins/optimization-detective/detection.php
+++ b/plugins/optimization-detective/detection.php
@@ -264,6 +264,31 @@ function_exists( 'gzdecode' ) &&
return $result;
}
+/**
+ * Registers the REST API endpoint for priming URL Metrics.
+ *
+ * @since n.e.x.t
+ * @access private
+ */
+function od_register_rest_url_metric_priming_endpoint(): void {
+ $endpoint_controller = new OD_REST_URL_Metrics_Priming_Mode_Endpoint();
+ register_rest_route(
+ OD_REST_URL_Metrics_Store_Endpoint::ROUTE_NAMESPACE,
+ $endpoint_controller::PRIME_URLS_ROUTE,
+ $endpoint_controller->get_registration_args_prime_urls()
+ );
+ register_rest_route(
+ OD_REST_URL_Metrics_Store_Endpoint::ROUTE_NAMESPACE,
+ $endpoint_controller::PRIME_URLS_VIEWPORTS_ROUTE,
+ $endpoint_controller->get_registration_args_prime_urls_viewports()
+ );
+ register_rest_route(
+ OD_REST_URL_Metrics_Store_Endpoint::ROUTE_NAMESPACE,
+ $endpoint_controller::PRIME_URLS_VERIFICATION_TOKEN_ROUTE,
+ $endpoint_controller->get_registration_args_prime_urls_verification_token()
+ );
+}
+
/**
* Triggers post update actions for page caches to invalidate their caches related to the supplied cache purge post ID.
*
diff --git a/plugins/optimization-detective/helper.php b/plugins/optimization-detective/helper.php
index 75f985102a..406d89943d 100644
--- a/plugins/optimization-detective/helper.php
+++ b/plugins/optimization-detective/helper.php
@@ -240,3 +240,529 @@ function od_get_asset_path( string $src_path, ?string $min_path = null ): string
return $min_path;
}
+
+/**
+ * Enqueues scripts for the URL priming in the admin area.
+ *
+ * @since n.e.x.t
+ * @access private
+ *
+ * @param string $hook_suffix Current admin page.
+ */
+function od_enqueue_prime_url_metrics_scripts( string $hook_suffix ): void {
+ if ( 'tools_page_od-optimization-detective' === $hook_suffix ) {
+ wp_enqueue_script(
+ 'od-prime-url-metrics',
+ plugins_url( od_get_asset_path( 'prime-url-metrics.js' ), __FILE__ ),
+ array( 'wp-i18n', 'wp-api-fetch' ),
+ OPTIMIZATION_DETECTIVE_VERSION,
+ true
+ );
+ }
+
+ if (
+ 'post.php' === $hook_suffix &&
+ function_exists( 'get_current_screen' ) &&
+ isset( $_GET['od_classic_editor_post_update_nonce'] ) &&
+ false !== wp_verify_nonce( sanitize_text_field( wp_unslash( $_GET['od_classic_editor_post_update_nonce'] ) ), 'od_classic_editor_post_update' ) &&
+ isset( $_GET['post'] ) &&
+ isset( $_GET['message'] ) &&
+ 1 === (int) $_GET['message']
+ ) {
+ $screen = get_current_screen();
+ if ( $screen instanceof WP_Screen && ! $screen->is_block_editor() ) {
+ $permalink = get_permalink( (int) $_GET['post'] );
+
+ if ( false !== $permalink ) {
+ wp_enqueue_script(
+ 'od-prime-url-metrics-classic-editor',
+ plugins_url( od_get_asset_path( 'prime-url-metrics-classic-editor.js' ), __FILE__ ),
+ array( 'wp-i18n', 'wp-api-fetch' ),
+ OPTIMIZATION_DETECTIVE_VERSION,
+ true
+ );
+
+ wp_localize_script(
+ 'od-prime-url-metrics-classic-editor',
+ 'odPrimeURLMetricsClassicEditor',
+ array(
+ 'permalink' => $permalink,
+ )
+ );
+ }
+ }
+ }
+}
+
+/**
+ * Enqueues scripts for the URL priming in block editor.
+ *
+ * @since n.e.x.t
+ * @access private
+ */
+function od_enqueue_block_editor_prime_url_metrics_scripts(): void {
+ wp_enqueue_script(
+ 'od-prime-url-metrics',
+ plugins_url( od_get_asset_path( 'prime-url-metrics-block-editor.js' ), __FILE__ ),
+ array( 'wp-data', 'wp-api-fetch' ),
+ OPTIMIZATION_DETECTIVE_VERSION,
+ true
+ );
+}
+
+/**
+ * Adds a nonce to the post update redirect URL for the classic editor.
+ *
+ * @since n.e.x.t
+ * @access private
+ *
+ * @param string $location The redirect URL.
+ * @return string The updated redirect URL.
+ */
+function od_add_data_to_post_update_redirect_url_for_classic_editor( string $location ): string {
+ return add_query_arg( 'od_classic_editor_post_update_nonce', wp_create_nonce( 'od_classic_editor_post_update' ), $location );
+}
+
+/**
+ * Gets URLs for priming URL Metrics from sitemap in batches.
+ *
+ * @since n.e.x.t
+ * @access private
+ *
+ * @param array $cursor Cursor to resume from.
+ * @return array Batch of URLs to prime metrics for and the updated cursor.
+ */
+function od_get_priming_mode_batch( array $cursor ): array {
+ // Get the server & its registry of sitemap providers.
+ $server = wp_sitemaps_get_server();
+ $registry = $server->registry;
+
+ // All registered providers.
+ $providers = array_values( $registry->get_providers() ); // Ensure zero-based index.
+
+ $all_urls = array();
+ $collected_count = 0;
+
+ // Flag to indicate if we should stop collecting further URLs (i.e., we reached $cursor['batch_size']).
+ $done = false;
+
+ // Start iterating from the current provider_index forward.
+ $providers_count = count( $providers );
+ for ( $provider_index = $cursor['provider_index']; $provider_index < $providers_count && ! $done; ) {
+ $provider = $providers[ $provider_index ];
+
+ // WordPress providers return an array of strings from get_object_subtypes().
+ $subtypes = array_values( $provider->get_object_subtypes() ); // zero-based index.
+
+ // Start from the current subtype_index if resuming.
+ $subtypes_count = count( $subtypes );
+ for ( $subtype_index = ( $provider_index === $cursor['provider_index'] ) ? $cursor['subtype_index'] : 0; $subtype_index < $subtypes_count && ! $done; ) {
+ // This is a string, e.g. 'post', 'page', etc.
+ $subtype = $subtypes[ $subtype_index ];
+
+ // Retrieve the max number of pages for this subtype.
+ $max_num_pages = $provider->get_max_num_pages( $subtype->name );
+
+ // Start from the current page_number if resuming.
+ for ( $page = ( ( $provider_index === $cursor['provider_index'] ) && ( $subtype_index === $cursor['subtype_index'] ) ) ? $cursor['page_number'] : 1; $page <= $max_num_pages && ! $done; ++$page ) {
+ $url_list = $provider->get_url_list( $page, $subtype->name );
+ if ( ! is_array( $url_list ) ) {
+ continue;
+ }
+
+ // Filter out empty URLs.
+ $url_chunk = array_filter( array_column( $url_list, 'loc' ) );
+
+ // We might have partially consumed this page, so skip $cursor['offset_within_page'] items first.
+ $current_page_urls = array_slice( $url_chunk, $cursor['offset_within_page'] );
+
+ // Count how many URLs we consumed in this page.
+ $consumed_in_this_page = 0;
+
+ // Now collect from current_page_urls until we reach $cursor['batch_size'].
+ foreach ( $current_page_urls as $url ) {
+ $all_urls[] = $url;
+ ++$collected_count;
+ ++$consumed_in_this_page;
+
+ if ( $collected_count >= $cursor['batch_size'] ) {
+ // We have our full batch; stop collecting further.
+ $done = true;
+ break;
+ }
+ }
+
+ if ( ! $done ) {
+ // We consumed this entire page, so if we continue, next time we start at offset 0 of the next page.
+ $cursor['page_number'] = $page + 1;
+ $cursor['offset_within_page'] = 0;
+ } else {
+ // We reached the limit in the middle of this page.
+ // Figure out how many we used from this page to update the offset properly.
+ $extra_consumed = $collected_count - $cursor['batch_size']; // If exactly $cursor['batch_size'], this might be 0 or negative.
+ if ( $extra_consumed < 0 ) {
+ $extra_consumed = 0;
+ }
+
+ $cursor['offset_within_page'] = $cursor['offset_within_page'] + ( $consumed_in_this_page - $extra_consumed );
+
+ // We haven't fully finished this page, so keep the same $cursor['page_number'].
+ $cursor['page_number'] = $page;
+ }
+ } // end for pages.
+
+ if ( ! $done ) {
+ // If we've finished all pages in this subtype, move to next subtype from the start (page 1, offset 0).
+ $cursor['page_number'] = 1;
+ $cursor['offset_within_page'] = 0;
+ }
+
+ $cursor['subtype_index'] = $subtype_index;
+ ++$subtype_index;
+ } // end for subtypes.
+
+ if ( ! $done ) {
+ // If we finished all subtypes in this provider, move to next provider and start at subtype=0, page=1.
+ $cursor['subtype_index'] = 0;
+ $cursor['page_number'] = 1;
+ $cursor['offset_within_page'] = 0;
+ }
+
+ $cursor['provider_index'] = $provider_index;
+ ++$provider_index;
+ } // end for providers.
+
+ // Prepare next cursor.
+ $new_cursor = array(
+ 'provider_index' => $cursor['provider_index'],
+ 'subtype_index' => $cursor['subtype_index'],
+ 'page_number' => $cursor['page_number'],
+ 'offset_within_page' => $cursor['offset_within_page'],
+ 'batch_size' => $cursor['batch_size'],
+ );
+
+ return array(
+ 'urls' => $all_urls,
+ 'cursor' => $new_cursor,
+ );
+}
+
+/**
+ * Filter for WP_Query to allow specifying 'post_title__in' => array( 'title1', 'title2', ... ).
+ *
+ * This is needed because WP_Query does not support filtering by post_title.
+ *
+ * @since n.e.x.t
+ * @access private
+ *
+ * @param string $where The WHERE clause of the query.
+ * @param WP_Query $query The WP_Query instance.
+ */
+function od_filter_posts_where_for_titles( string $where, WP_Query $query ): string {
+ global $wpdb;
+
+ $titles = (array) $query->get( 'post_title__in', array() );
+ $titles = array_filter( $titles );
+
+ if ( 0 === count( $titles ) ) {
+ return $where;
+ }
+
+ // Safely prepare each title for IN() clause.
+ $placeholders = array();
+ foreach ( $titles as $title ) {
+ $placeholders[] = $wpdb->prepare( '%s', $title );
+ }
+ $list = implode( ',', $placeholders );
+
+ $where .= " AND {$wpdb->posts}.post_title IN ($list)";
+ return $where;
+}
+
+/**
+ * Fetches od_url_metrics posts of URLs in a single WP_Query.
+ *
+ * This function is used to reduce the number of database queries done by querying all URLs in a
+ * single query instead of one per URL.
+ *
+ * @since n.e.x.t
+ * @access private
+ *
+ * @param string[] $urls Array of exact URLs, as stored in post_title of od_url_metrics.
+ * @return array Map of URL to its OD_URL_Metric_Group_Collection.
+ */
+function od_get_metrics_by_post_title( array $urls ): array {
+ $urls = array_unique( array_filter( $urls ) );
+ if ( 0 === count( $urls ) ) {
+ return array();
+ }
+
+ $results_map = array();
+
+ add_filter( 'posts_where', 'od_filter_posts_where_for_titles', 10, 2 );
+
+ $query = new WP_Query(
+ array(
+ 'post_type' => OD_URL_Metrics_Post_Type::SLUG,
+ 'post_status' => 'publish',
+ 'post_title__in' => $urls,
+ // Currently the count of urls is 10 or less for each batch so we can use -1 for now.
+ 'posts_per_page' => -1,
+ 'no_found_rows' => true,
+ 'update_post_meta_cache' => false,
+ 'update_post_term_cache' => false,
+ 'fields' => 'all',
+ )
+ );
+
+ remove_filter( 'posts_where', 'od_filter_posts_where_for_titles', 10 );
+
+ foreach ( $query->posts as $post ) {
+ if ( ! $post instanceof WP_Post ) {
+ continue;
+ }
+ $results_map[ $post->post_title ] = new OD_URL_Metric_Group_Collection(
+ OD_URL_Metrics_Post_Type::get_url_metrics_from_post( $post ),
+ md5( '' ), // This is a dummy hash.
+ od_get_breakpoint_max_widths(),
+ od_get_url_metrics_breakpoint_sample_size(),
+ od_get_url_metric_freshness_ttl()
+ );
+ }
+ return $results_map;
+}
+
+/**
+ * Gets the standard array of viewport based on real world device.
+ *
+ * @since n.e.x.t
+ * @access private
+ *
+ * @return array Array of viewports.
+ */
+function od_get_standard_viewports(): array {
+ $device_viewports = array(
+ array( // Small smartphones.
+ 'width' => 360,
+ 'height' => 780,
+ ),
+ array( // Large smartphones.
+ 'width' => 414,
+ 'height' => 896,
+ ),
+ array( // Tablets.
+ 'width' => 768,
+ 'height' => 1024,
+ ),
+ array( // Desktop/laptop screens.
+ 'width' => 1920,
+ 'height' => 1080,
+ ),
+ );
+
+ /**
+ * Filters the standard device viewports used for priming mode.
+ *
+ * @since n.e.x.t
+ *
+ * @param array $device_viewports Array of viewport dimensions.
+ */
+ return apply_filters( 'od_standard_viewports', $device_viewports );
+}
+
+/**
+ * Filters the batch of URLs to only include those that need additional metrics.
+ *
+ * @since n.e.x.t
+ * @access private
+ *
+ * @param array $urls Array of URLs to filter.
+ * @return array}> Filtered batch of URL groups.
+ */
+function od_filter_priming_mode_batch_urls( array $urls ): array {
+ $filtered_url_groups = array();
+ $standard_viewports = od_get_standard_viewports();
+ $group_collections = od_get_metrics_by_post_title( $urls );
+
+ foreach ( $urls as $url ) {
+ $group_collection = $group_collections[ $url ] ?? null;
+ if ( ! $group_collection instanceof OD_URL_Metric_Group_Collection ) {
+ $filtered_url_groups[] = array(
+ 'url' => $url,
+ 'viewports' => $standard_viewports,
+ );
+ continue;
+ }
+
+ if ( $group_collection->is_every_group_populated() ) {
+ continue;
+ }
+
+ $existing_widths = array();
+ foreach ( $group_collection as $group ) {
+ if ( ! $group->is_complete() ) {
+ foreach ( $group as $url_metric ) {
+ $existing_widths[] = $url_metric->get_viewport_width();
+ }
+ }
+ }
+
+ $missing_viewports = array();
+ foreach ( $standard_viewports as $viewport ) {
+ if ( ! in_array( $viewport['width'], $existing_widths, true ) ) {
+ $missing_viewports[] = $viewport;
+ }
+ }
+
+ if ( count( $missing_viewports ) > 0 ) {
+ $filtered_url_groups[] = array(
+ 'url' => $url,
+ 'viewports' => $missing_viewports,
+ );
+ }
+ }
+
+ return $filtered_url_groups;
+}
+
+/**
+ * Determine whether to show the priming mode settings page.
+ *
+ * @since n.e.x.t
+ *
+ * @return bool True to display the settings page; false to hide it.
+ */
+function od_show_priming_mode_settings(): bool {
+ /**
+ * Filters whether the priming mode settings page should be shown in the admin dashboard, regardless of the number of URLs.
+ *
+ * @since n.e.x.t
+ *
+ * @param bool $show_feature True if the feature should be shown, false otherwise.
+ */
+ $force_show = apply_filters( 'od_show_priming_mode_settings', false );
+ if ( true === $force_show ) {
+ return true;
+ }
+
+ /**
+ * Filters maximum number of URLs allowed before hiding the settings page.
+ *
+ * @since n.e.x.t
+ *
+ * @param int $threshold The threshold count of frontend-visible URLs.
+ */
+ $threshold = apply_filters( 'od_show_priming_mode_settings_max_urls', 1000 );
+
+ $count = (int) get_transient( 'od_priming_mode_frontend_visible_url_count' );
+ if ( 0 !== $count ) {
+ return $count <= $threshold;
+ }
+
+ // Get the sitemap server and its registry of providers.
+ $server = wp_sitemaps_get_server();
+ $registry = $server->registry;
+ $providers = array_values( $registry->get_providers() );
+ $show = true;
+
+ foreach ( $providers as $provider ) {
+ // Each provider returns its object subtypes (e.g. 'post', 'page', etc.).
+ $subtypes = array_values( $provider->get_object_subtypes() );
+ foreach ( $subtypes as $subtype ) {
+ $max_pages = $provider->get_max_num_pages( $subtype->name );
+ for ( $page = 1; $page <= $max_pages; $page++ ) {
+ $url_list = $provider->get_url_list( $page, $subtype->name );
+ if ( ! is_array( $url_list ) ) {
+ continue;
+ }
+
+ $url_chunk = array_filter( array_column( $url_list, 'loc' ) );
+ $count += count( $url_chunk );
+
+ if ( $count >= $threshold ) {
+ $show = false;
+ break 3;
+ }
+ }
+ }
+ }
+
+ set_transient( 'od_priming_mode_frontend_visible_url_count', $count, DAY_IN_SECONDS );
+
+ return $show;
+}
+
+/**
+ * Generates the batch of URLs for priming URL Metrics.
+ *
+ * @since n.e.x.t
+ * @access private
+ *
+ * @param array|null $cursor Cursor to resume from.
+ * @return array Final batch of URLs to prime metrics for and the updated cursor.
+ */
+function od_generate_priming_mode_batch( ?array $cursor ): array {
+ $default_cursor = array(
+ 'provider_index' => 0,
+ 'subtype_index' => 0,
+ 'page_number' => 1,
+ 'offset_within_page' => 0,
+ 'batch_size' => 10,
+ );
+
+ // Validate the cursor.
+ $cursor = array_map( 'intval', array_intersect_key( wp_parse_args( (array) $cursor, $default_cursor ), $default_cursor ) );
+
+ if ( $default_cursor === $cursor ) {
+ $last_cursor = get_option( 'od_priming_mode_batch_cursor' );
+ if ( false !== $last_cursor ) {
+ $cursor = array_map( 'intval', array_intersect_key( wp_parse_args( $cursor, $last_cursor ), $last_cursor ) );
+ }
+ } else {
+ update_option( 'od_priming_mode_batch_cursor', $cursor );
+ }
+
+ $batch = array();
+ $filtered_url_groups = array();
+ $prevent_infinite_loop = 0;
+ while ( $prevent_infinite_loop < 100 ) {
+ if ( count( $filtered_url_groups ) > 0 ) {
+ break;
+ }
+
+ $batch = od_get_priming_mode_batch( $cursor );
+ $filtered_url_groups = od_filter_priming_mode_batch_urls( $batch['urls'] );
+
+ if ( $cursor === $batch['cursor'] ) {
+ delete_option( 'od_priming_mode_batch_cursor' );
+ break;
+ }
+ $cursor = $batch['cursor'];
+
+ ++$prevent_infinite_loop;
+ }
+
+ return array(
+ 'urlGroups' => $filtered_url_groups,
+ 'cursor' => $batch['cursor'],
+ 'verificationToken' => od_get_priming_mode_verification_token(),
+ 'isDebug' => defined( 'WP_DEBUG' ) && WP_DEBUG,
+ );
+}
+
+/**
+ * Gets the verification token for priming mode.
+ *
+ * @since n.e.x.t
+ * @access private
+ *
+ * @return string Verification token.
+ */
+function od_get_priming_mode_verification_token(): string {
+ $verification_token = get_transient( 'od_priming_mode_verification_token' );
+ if ( false === $verification_token ) {
+ $verification_token = wp_generate_uuid4();
+ set_transient( 'od_priming_mode_verification_token', $verification_token, 30 * MINUTE_IN_SECONDS );
+ }
+ return $verification_token;
+}
diff --git a/plugins/optimization-detective/hooks.php b/plugins/optimization-detective/hooks.php
index 584e88e745..7df99d41c0 100644
--- a/plugins/optimization-detective/hooks.php
+++ b/plugins/optimization-detective/hooks.php
@@ -24,7 +24,13 @@
add_filter( 'site_status_tests', 'od_add_rest_api_availability_test' );
add_action( 'admin_init', 'od_maybe_run_rest_api_health_check' );
add_action( 'after_plugin_row_meta', 'od_render_rest_api_health_check_admin_notice_in_plugin_row', 30 );
+add_action( 'admin_menu', 'od_add_optimization_detective_menu' );
+add_action( 'admin_enqueue_scripts', 'od_enqueue_prime_url_metrics_scripts' );
+add_action( 'enqueue_block_editor_assets', 'od_enqueue_block_editor_prime_url_metrics_scripts' );
+add_filter( 'redirect_post_location', 'od_add_data_to_post_update_redirect_url_for_classic_editor' );
+add_filter( 'plugin_action_links_' . OPTIMIZATION_DETECTIVE_MAIN_FILE, 'od_add_settings_action_link' );
add_action( 'rest_api_init', 'od_register_rest_url_metric_store_endpoint' );
add_filter( 'rest_pre_dispatch', 'od_decompress_rest_request_body', 10, 3 );
+add_action( 'rest_api_init', 'od_register_rest_url_metric_priming_endpoint' );
add_action( 'od_trigger_page_cache_invalidation', 'od_trigger_post_update_actions' );
// @codeCoverageIgnoreEnd
diff --git a/plugins/optimization-detective/load.php b/plugins/optimization-detective/load.php
index 74a1e0c1e1..952d663768 100644
--- a/plugins/optimization-detective/load.php
+++ b/plugins/optimization-detective/load.php
@@ -96,6 +96,7 @@ static function ( string $version ): void {
}
define( 'OPTIMIZATION_DETECTIVE_VERSION', $version );
+ define( 'OPTIMIZATION_DETECTIVE_MAIN_FILE', plugin_basename( __FILE__ ) );
require_once __DIR__ . '/helper.php';
@@ -116,6 +117,7 @@ static function ( string $version ): void {
require_once __DIR__ . '/storage/class-od-storage-lock.php';
require_once __DIR__ . '/storage/data.php';
require_once __DIR__ . '/storage/class-od-rest-url-metrics-store-endpoint.php';
+ require_once __DIR__ . '/storage/class-od-rest-url-metrics-priming-mode-endpoint.php';
require_once __DIR__ . '/storage/class-od-url-metric-store-request-context.php';
// Detection logic.
@@ -134,5 +136,11 @@ static function ( string $version ): void {
// Load site health checks.
require_once __DIR__ . '/site-health.php';
+
+ // Load the settings page.
+ require_once __DIR__ . '/settings.php';
+
+ // Load WP-CLI commands.
+ require_once __DIR__ . '/storage/class-od-priming-mode-wp-cli.php';
}
);
diff --git a/plugins/optimization-detective/prime-url-metrics-block-editor.js b/plugins/optimization-detective/prime-url-metrics-block-editor.js
new file mode 100644
index 0000000000..dfd0bfefb8
--- /dev/null
+++ b/plugins/optimization-detective/prime-url-metrics-block-editor.js
@@ -0,0 +1,299 @@
+/**
+ * Helper script for the Priming URL Metrics in block editor.
+ */
+( function () {
+ // @ts-ignore
+ const { select, subscribe } = wp.data;
+ // @ts-ignore
+ const { apiFetch } = wp;
+
+ /**
+ * Flag indicating whether URL priming is currently in progress.
+ *
+ * @type {boolean}
+ */
+ let isProcessing = false;
+
+ /**
+ * Token used for verifying REST API requests server side.
+ *
+ * @type {string}
+ */
+ let verificationToken = '';
+
+ /**
+ * Array of viewport objects defining dimensions.
+ *
+ * @type {import("./types.ts").Viewport[]}
+ */
+ let viewports = [];
+
+ /**
+ * Queue of URL priming tasks generated from viewports.
+ *
+ * @type {import("./types.ts").URLPrimingTask[]}
+ */
+ let currentTasks = [];
+
+ /**
+ * Index of the current task within currentTasks being processed.
+ *
+ * @type {number}
+ */
+ let currentTaskIndex = 0;
+
+ /**
+ * Flag indicating whether the document tab/window is hidden.
+ *
+ * @type {boolean}
+ */
+ let isTabHidden = false;
+
+ /**
+ * AbortController instance to support aborting ongoing task.
+ *
+ * @type {AbortController}
+ */
+ let abortController = new AbortController();
+
+ /**
+ * Promise tracking the processing of tasks to ensure sequential execution.
+ *
+ * @type {?Promise}
+ */
+ let processTasksPromise = null;
+
+ /**
+ * Prefix which is prepended to messages logged to the console while in priming mode.
+ *
+ * @type {string}
+ */
+ const consoleLogPrefix = '[Optimization Detective Priming Mode]';
+
+ /**
+ * Hidden iframe element used to load pages for metric priming.
+ *
+ * @type {HTMLIFrameElement}
+ */
+ const iframe = document.createElement( 'iframe' );
+ iframe.id = 'od-priming-mode-iframe';
+ iframe.style.position = 'fixed';
+ iframe.style.top = '0';
+ iframe.style.left = '0';
+ iframe.style.transform = 'scale(0.05)';
+ iframe.style.transformOrigin = '0 0';
+ iframe.style.pointerEvents = 'none';
+ iframe.style.opacity = '0.000001';
+ iframe.style.zIndex = '-9999';
+ document.body.appendChild( iframe );
+
+ /**
+ * Logs messages to the console.
+ *
+ * @param {...*} message - The message(s) to log.
+ */
+ function log( ...message ) {
+ // eslint-disable-next-line no-console
+ console.log( consoleLogPrefix, ...message );
+ }
+
+ /**
+ * Primes the URL metrics for all viewports.
+ *
+ * @return {Promise} The promise that resolves to void.
+ */
+ async function processTasks() {
+ try {
+ isProcessing = true;
+ if ( 0 === viewports.length ) {
+ viewports = await apiFetch( {
+ path: '/optimization-detective/v1/priming-mode-viewports',
+ method: 'GET',
+ } );
+ }
+
+ const permalink = select( 'core/editor' ).getPermalink();
+ verificationToken = await apiFetch( {
+ path: '/optimization-detective/v1/priming-mode-verification-token',
+ method: 'GET',
+ } );
+
+ currentTasks = viewports.map( ( viewport ) => ( {
+ url: permalink,
+ width: viewport.width,
+ height: viewport.height,
+ } ) );
+
+ while ( isProcessing && currentTaskIndex < currentTasks.length ) {
+ try {
+ await processTask(
+ currentTasks[ currentTaskIndex ],
+ abortController.signal
+ );
+ } catch ( error ) {
+ log( error.message );
+ if ( abortController.signal.aborted ) {
+ throw error;
+ }
+ }
+ currentTaskIndex++;
+ }
+ isProcessing = false;
+ } catch ( error ) {
+ isProcessing = false;
+ }
+ }
+
+ /**
+ * Loads the iframe and waits for the message.
+ *
+ * @param {import("./types.ts").URLPrimingTask} task - The viewport to set for the iframe.
+ * @param {AbortSignal} signal - The signal to abort the task.
+ * @return {Promise} The promise that resolves to void.
+ */
+ async function processTask( task, signal ) {
+ return new Promise( ( resolve, reject ) => {
+ /**
+ * Handles the message from the iframe.
+ *
+ * @param {MessageEvent} event - The message event.
+ * @return {Promise} The promise that resolves to void.
+ */
+ async function handleMessage( event ) {
+ if (
+ event.data &&
+ event.data.type &&
+ 'OD_PRIME_URL_METRICS_REQUEST_STATUS' === event.data.type
+ ) {
+ if ( event.data.success ) {
+ await cleanup();
+ resolve();
+ } else {
+ await cleanup();
+ reject(
+ new Error(
+ event.data.error || 'URL Metric request failed'
+ )
+ );
+ }
+ }
+ }
+
+ /**
+ * Handles the aborting of the task on abort signal.
+ *
+ * @return {Promise} The promise that resolves to void.
+ */
+ async function abortHandler() {
+ await cleanup();
+ reject( new Error( 'Task Aborted' ) );
+ }
+
+ /**
+ * Cleans up the event listeners and iframe.
+ *
+ * @return {Promise} The promise that resolves to void.
+ */
+ function cleanup() {
+ return new Promise( ( cleanUpResolve ) => {
+ signal.removeEventListener( 'abort', abortHandler );
+ window.removeEventListener( 'message', handleMessage );
+ clearTimeout( timeoutId );
+ iframe.onerror = null;
+ iframe.src = 'about:blank';
+ iframe.addEventListener( 'load', () => cleanUpResolve(), {
+ once: true,
+ } );
+ } );
+ }
+
+ const timeoutId = setTimeout( async () => {
+ await cleanup();
+ reject( new Error( 'Timeout waiting for message' ) );
+ }, 30000 ); // 30-second timeout
+
+ if ( signal.aborted ) {
+ abortHandler();
+ return;
+ }
+
+ signal.addEventListener( 'abort', abortHandler );
+ window.addEventListener( 'message', handleMessage );
+
+ iframe.onerror = async () => {
+ await cleanup();
+ reject( new Error( 'Iframe failed to load' ) );
+ };
+
+ const url = new URL( task.url );
+ url.hash = `odPrimeModeVerificationToken=${ encodeURIComponent(
+ verificationToken
+ ) }&odPrimeModeSource=admin-dashboard`;
+
+ // Load the iframe.
+ iframe.src = url.toString();
+ iframe.width = task.width.toString();
+ iframe.height = task.height.toString();
+ } );
+ }
+
+ /**
+ * Handles visibility change events to pause/resume processing when tab/window visibility changes.
+ */
+ function handleVisibilityChange() {
+ if ( 'hidden' === document.visibilityState && isProcessing ) {
+ isProcessing = false;
+ isTabHidden = true;
+ if ( ! abortController.signal.aborted ) {
+ abortController.abort();
+ }
+ } else if ( 'visible' === document.visibilityState && isTabHidden ) {
+ isTabHidden = false;
+ if ( ! isProcessing ) {
+ isProcessing = true;
+ if ( abortController.signal.aborted ) {
+ abortController = new AbortController();
+ }
+ processTasks();
+ }
+ }
+ }
+
+ /**
+ * Handler for the beforeunload event to prevent accidental page navigation.
+ *
+ * @param {BeforeUnloadEvent} event - The beforeunload event
+ */
+ function handleBeforeUnload( event ) {
+ if ( isProcessing ) {
+ event.preventDefault();
+ }
+ }
+
+ // Listen for post save/publish events.
+ let wasSaving = false;
+ subscribe( async () => {
+ const isSaving = select( 'core/editor' ).isSavingPost();
+ const isAutosaving = select( 'core/editor' ).isAutosavingPost();
+
+ // Trigger when saving transitions from true to false (save completed).
+ if ( wasSaving && ! isSaving && ! isAutosaving ) {
+ wasSaving = false;
+ if ( processTasksPromise ) {
+ if ( ! abortController.signal.aborted ) {
+ abortController.abort();
+ }
+ await processTasksPromise;
+ currentTaskIndex = 0;
+ abortController = new AbortController();
+ }
+ processTasksPromise = processTasks();
+ } else {
+ wasSaving = isSaving;
+ }
+ } );
+
+ // Attach event listeners.
+ document.addEventListener( 'visibilitychange', handleVisibilityChange );
+ window.addEventListener( 'beforeunload', handleBeforeUnload );
+} )();
diff --git a/plugins/optimization-detective/prime-url-metrics-classic-editor.js b/plugins/optimization-detective/prime-url-metrics-classic-editor.js
new file mode 100644
index 0000000000..d40a0f1be3
--- /dev/null
+++ b/plugins/optimization-detective/prime-url-metrics-classic-editor.js
@@ -0,0 +1,305 @@
+/**
+ * Helper script for the Priming URL Metrics in classic editor.
+ */
+
+/* global odPrimeURLMetricsClassicEditor */
+( function ( odPrimeURLMetricsClassicEditor ) {
+ // @ts-ignore
+ if ( 'undefined' === typeof odPrimeURLMetricsClassicEditor ) {
+ return;
+ }
+
+ /**
+ * Permalink URL for the current post in classic editor.
+ *
+ * @type {string}
+ */
+ const permalink = odPrimeURLMetricsClassicEditor.permalink;
+
+ // @ts-ignore
+ const { apiFetch } = wp;
+
+ /**
+ * Flag indicating whether URL priming is currently in progress.
+ *
+ * @type {boolean}
+ */
+ let isProcessing = false;
+
+ /**
+ * Token used for verifying REST API requests server side.
+ *
+ * @type {string}
+ */
+ let verificationToken = '';
+
+ /**
+ * Array of viewport objects defining dimensions.
+ *
+ * @type {import("./types.ts").Viewport[]}
+ */
+ let viewports = [];
+
+ /**
+ * Queue of URL priming tasks generated from viewports.
+ *
+ * @type {import("./types.ts").URLPrimingTask[]}
+ */
+ let currentTasks = [];
+
+ /**
+ * Index of the current task within currentTasks being processed.
+ *
+ * @type {number}
+ */
+ let currentTaskIndex = 0;
+
+ /**
+ * Flag indicating whether the document tab/window is hidden.
+ *
+ * @type {boolean}
+ */
+ let isTabHidden = false;
+
+ /**
+ * AbortController instance to support aborting ongoing task.
+ *
+ * @type {AbortController}
+ */
+ let abortController = new AbortController();
+
+ /**
+ * Prefix which is prepended to messages logged to the console while in priming mode.
+ *
+ * @type {string}
+ */
+ const consoleLogPrefix = '[Optimization Detective Priming Mode]';
+
+ /**
+ * Button element for publishing, allowing page leave after click.
+ *
+ * @type {HTMLInputElement}
+ */
+ const updateButton = document.querySelector( 'input#publish' );
+
+ /**
+ * Flag that indicates if navigation away from page is allowed.
+ *
+ * @type {boolean}
+ */
+ let allowLeavingPage = false;
+
+ /**
+ * Hidden iframe element used to load pages for metric priming.
+ *
+ * @type {HTMLIFrameElement}
+ */
+ const iframe = document.createElement( 'iframe' );
+ iframe.id = 'od-priming-mode-iframe';
+ iframe.style.position = 'fixed';
+ iframe.style.top = '0';
+ iframe.style.left = '0';
+ iframe.style.transform = 'scale(0.05)';
+ iframe.style.transformOrigin = '0 0';
+ iframe.style.pointerEvents = 'none';
+ iframe.style.opacity = '0.000001';
+ iframe.style.zIndex = '-9999';
+ document.body.appendChild( iframe );
+
+ /**
+ * Logs messages to the console.
+ *
+ * @param {...*} message - The message(s) to log.
+ */
+ function log( ...message ) {
+ // eslint-disable-next-line no-console
+ console.log( consoleLogPrefix, ...message );
+ }
+
+ /**
+ * Primes the URL metrics for all viewports.
+ *
+ * @return {Promise} The promise that resolves to void.
+ */
+ async function processTasks() {
+ try {
+ isProcessing = true;
+ if ( 0 === viewports.length ) {
+ viewports = await apiFetch( {
+ path: '/optimization-detective/v1/priming-mode-viewports',
+ method: 'GET',
+ } );
+ }
+
+ verificationToken = await apiFetch( {
+ path: '/optimization-detective/v1/priming-mode-verification-token',
+ method: 'GET',
+ } );
+
+ currentTasks = viewports.map( ( viewport ) => ( {
+ url: permalink,
+ width: viewport.width,
+ height: viewport.height,
+ } ) );
+
+ while ( isProcessing && currentTaskIndex < currentTasks.length ) {
+ try {
+ await processTask(
+ currentTasks[ currentTaskIndex ],
+ abortController.signal
+ );
+ } catch ( error ) {
+ log( error );
+ if ( abortController.signal.aborted ) {
+ throw error;
+ }
+ }
+ currentTaskIndex++;
+ }
+ isProcessing = false;
+ } catch ( error ) {
+ isProcessing = false;
+ }
+ }
+
+ /**
+ * Loads the iframe and waits for the message.
+ *
+ * @param {import("./types.ts").URLPrimingTask} task - The viewport to set for the iframe.
+ * @param {AbortSignal} signal - The signal to abort the task.
+ * @return {Promise} The promise that resolves to void.
+ */
+ function processTask( task, signal ) {
+ return new Promise( ( resolve, reject ) => {
+ /**
+ * Handles the message from the iframe.
+ *
+ * @param {MessageEvent} event - The message event.
+ * @return {Promise} The promise that resolves to void.
+ */
+ async function handleMessage( event ) {
+ if (
+ event.data &&
+ event.data.type &&
+ 'OD_PRIME_URL_METRICS_REQUEST_STATUS' === event.data.type
+ ) {
+ if ( event.data.success ) {
+ await cleanup();
+ resolve();
+ } else {
+ await cleanup();
+ reject(
+ new Error(
+ event.data.error || 'URL Metric request failed'
+ )
+ );
+ }
+ }
+ }
+
+ /**
+ * Handles the aborting of the task on abort signal.
+ *
+ * @return {Promise} The promise that resolves to void.
+ */
+ async function abortHandler() {
+ await cleanup();
+ reject( new Error( 'Task Aborted' ) );
+ }
+
+ /**
+ * Cleans up the event listeners and iframe.
+ *
+ * @return {Promise} The promise that resolves to void.
+ */
+ function cleanup() {
+ return new Promise( ( cleanUpResolve ) => {
+ signal.removeEventListener( 'abort', abortHandler );
+ window.removeEventListener( 'message', handleMessage );
+ clearTimeout( timeoutId );
+ iframe.onerror = null;
+ iframe.src = 'about:blank';
+ iframe.addEventListener( 'load', () => cleanUpResolve(), {
+ once: true,
+ } );
+ } );
+ }
+
+ const timeoutId = setTimeout( async () => {
+ await cleanup();
+ reject( new Error( 'Timeout waiting for message' ) );
+ }, 30000 ); // 30-second timeout
+
+ if ( signal.aborted ) {
+ abortHandler();
+ return;
+ }
+
+ signal.addEventListener( 'abort', abortHandler );
+ window.addEventListener( 'message', handleMessage );
+
+ iframe.onerror = async () => {
+ await cleanup();
+ reject( new Error( 'Iframe failed to load' ) );
+ };
+
+ const url = new URL( task.url );
+ url.hash = `odPrimeModeVerificationToken=${ encodeURIComponent(
+ verificationToken
+ ) }&odPrimeModeSource=admin-dashboard`;
+
+ // Load the iframe.
+ iframe.src = url.toString();
+ iframe.width = task.width.toString();
+ iframe.height = task.height.toString();
+ } );
+ }
+
+ /**
+ * Handles visibility change events to pause/resume processing when tab/window visibility changes.
+ */
+ function handleVisibilityChange() {
+ if ( 'hidden' === document.visibilityState && isProcessing ) {
+ isProcessing = false;
+ isTabHidden = true;
+ if ( ! abortController.signal.aborted ) {
+ abortController.abort();
+ }
+ } else if ( 'visible' === document.visibilityState && isTabHidden ) {
+ isTabHidden = false;
+ if ( ! isProcessing ) {
+ isProcessing = true;
+ if ( abortController.signal.aborted ) {
+ abortController = new AbortController();
+ }
+ processTasks();
+ }
+ }
+ }
+
+ /**
+ * Handler for the beforeunload event to prevent accidental page navigation.
+ *
+ * @param {BeforeUnloadEvent} event - The beforeunload event
+ */
+ function handleBeforeUnload( event ) {
+ if ( isProcessing && ! allowLeavingPage ) {
+ event.preventDefault();
+ }
+ }
+
+ // Attach event listeners.
+
+ /**
+ * Primes the URL metrics for all viewports
+ * when the document is ready.
+ */
+ document.addEventListener( 'DOMContentLoaded', processTasks );
+ document.addEventListener( 'visibilitychange', handleVisibilityChange );
+ window.addEventListener( 'beforeunload', handleBeforeUnload );
+ updateButton.addEventListener( 'click', () => {
+ allowLeavingPage = true;
+ } );
+
+ // @ts-ignore
+} )( odPrimeURLMetricsClassicEditor );
diff --git a/plugins/optimization-detective/prime-url-metrics.js b/plugins/optimization-detective/prime-url-metrics.js
new file mode 100644
index 0000000000..1680d38e58
--- /dev/null
+++ b/plugins/optimization-detective/prime-url-metrics.js
@@ -0,0 +1,545 @@
+/**
+ * Helper script for Priming URL Metrics through WordPress admin dashboard.
+ */
+( function () {
+ // @ts-ignore
+ const { i18n, apiFetch } = wp;
+ const { __ } = i18n;
+
+ /**
+ * Button element that toggles processing state.
+ *
+ * @type {HTMLButtonElement}
+ */
+ const controlButton = document.querySelector(
+ 'button#od-priming-mode-control-button'
+ );
+
+ /**
+ * Progress bar element displaying current task completion progress.
+ *
+ * @type {HTMLProgressElement}
+ */
+ const progressBar = document.querySelector(
+ 'progress#od-priming-mode-progress'
+ );
+
+ /**
+ * Container element displaying the status of URL metrics priming.
+ *
+ * @type {HTMLDivElement}
+ */
+ const statusContainer = document.querySelector(
+ 'div#od-priming-mode-status-container'
+ );
+
+ /**
+ * Iframe used to load pages for priming URL metrics.
+ *
+ * @type {HTMLIFrameElement}
+ */
+ const iframe = document.querySelector( 'iframe#od-priming-mode-iframe' );
+
+ /**
+ * Container that holds the iframe.
+ *
+ * @type {HTMLDivElement}
+ */
+ const iframeContainer = document.querySelector(
+ 'div#od-priming-mode-iframe-container'
+ );
+
+ /**
+ * Element that displays the current batch number being processed.
+ *
+ * @type {HTMLSpanElement}
+ */
+ const currentBatchElement = document.querySelector(
+ 'span#od-priming-mode-current-batch'
+ );
+
+ /**
+ * Element that displays the current task number being processed.
+ *
+ * @type {HTMLSpanElement}
+ */
+ const currentTaskElement = document.querySelector(
+ 'span#od-priming-mode-current-task'
+ );
+
+ /**
+ * Element that displays the total number of tasks in the current batch.
+ *
+ * @type {HTMLSpanElement}
+ */
+ const totalTasksInBatchElement = document.querySelector(
+ 'span#od-priming-mode-total-tasks-in-batch'
+ );
+
+ // Ensure all required elements are present.
+ if (
+ ! controlButton ||
+ ! progressBar ||
+ ! statusContainer ||
+ ! iframe ||
+ ! iframeContainer ||
+ ! currentBatchElement ||
+ ! currentTaskElement ||
+ ! totalTasksInBatchElement
+ ) {
+ return;
+ }
+
+ /**
+ * Flag indicating whether priming is currently in progress.
+ *
+ * @type {boolean}
+ */
+ let isProcessing = false;
+
+ /**
+ * Indicates whether more batches are available for processing.
+ *
+ * @type {boolean}
+ */
+ let isNextBatchAvailable = true;
+
+ /**
+ * Pagination cursor for retrieving the next batch of URLs.
+ *
+ * @type {?import("./types.ts").URLBatchCursor}
+ */
+ let cursor = null;
+
+ /**
+ * Flag indicating if debug mode is enabled.
+ *
+ * @type {boolean}
+ */
+ let isDebug = false;
+
+ /**
+ * Token used for verifying REST API requests server side.
+ *
+ * @type {string}
+ */
+ let verificationToken = '';
+
+ /**
+ * Currently active batch of data from the REST API.
+ *
+ * @type {?import("./types.ts").URLBatchResponse}
+ */
+ let currentBatch = null;
+
+ /**
+ * Array of URL priming tasks extracted from the current batch.
+ *
+ * @type {import("./types.ts").URLPrimingTask[]}
+ */
+ let currentTasks = [];
+
+ /**
+ * Index of the currently executing task in the batch.
+ *
+ * @type {number}
+ */
+ let currentTaskIndex = 0;
+
+ /**
+ * Running count of how many batches have been processed.
+ *
+ * @type {number}
+ */
+ let currentBatchNumber = 0;
+
+ /**
+ * Flag indicating whether the tab/window is hidden.
+ *
+ * @type {boolean}
+ */
+ let isTabHidden = false;
+
+ /**
+ * AbortController instance to support aborting ongoing task.
+ *
+ * @type {AbortController}
+ */
+ let abortController = new AbortController();
+
+ /**
+ * Prefix which is prepended to messages logged to the console while in priming mode.
+ *
+ * @type {string}
+ */
+ const consoleLogPrefix = '[Optimization Detective Priming Mode]';
+
+ /**
+ * ResizeObserver instance that adjusts iframe scale within container.
+ *
+ * @type {ResizeObserver}
+ */
+ const iframeObserver = new ResizeObserver( fitIframe );
+ iframeObserver.observe( iframe );
+
+ /**
+ * Logs messages to the console.
+ *
+ * @param {...*} message - The message(s) to log.
+ */
+ function log( ...message ) {
+ // eslint-disable-next-line no-console
+ console.log( consoleLogPrefix, ...message );
+ }
+
+ /**
+ * Toggles the processing state of the priming task.
+ */
+ function toggleProcessing() {
+ if ( isProcessing ) {
+ // Pause processing.
+ isProcessing = false;
+ controlButton.textContent = __(
+ 'Resume',
+ 'optimization-detective'
+ );
+ controlButton.classList.remove( 'updating-message' );
+ if ( ! abortController.signal.aborted ) {
+ abortController.abort();
+ }
+ } else {
+ // Start/resume processing.
+ isProcessing = true;
+ controlButton.textContent = __( 'Pause', 'optimization-detective' );
+ controlButton.classList.add( 'updating-message' );
+ if ( abortController.signal.aborted ) {
+ abortController = new AbortController();
+ }
+ statusContainer.style.display = 'block';
+ processBatches();
+ }
+ }
+
+ /**
+ * Processes batches of URL priming tasks.
+ */
+ async function processBatches() {
+ try {
+ while ( isProcessing ) {
+ if ( ! currentBatch ) {
+ await prepareNextBatch();
+ if ( ! isNextBatchAvailable ) {
+ break;
+ }
+ }
+
+ await processCurrentBatch();
+
+ // Reset batch state if all tasks in the batch are processed.
+ if ( currentTaskIndex >= currentTasks.length ) {
+ cursor = currentBatch.cursor;
+ currentBatch = null;
+ currentTasks = [];
+ currentTaskIndex = 0;
+ totalTasksInBatchElement.textContent = '0';
+ currentTaskElement.textContent = '0';
+ }
+ }
+ } catch ( error ) {
+ if ( ! isTabHidden && ! abortController.signal.aborted ) {
+ isProcessing = false;
+ controlButton.textContent = __(
+ 'Click to retry',
+ 'optimization-detective'
+ );
+ }
+ } finally {
+ if ( ! isNextBatchAvailable ) {
+ isProcessing = false;
+ controlButton.textContent = __(
+ 'Finished',
+ 'optimization-detective'
+ );
+ controlButton.disabled = true;
+ controlButton.classList.remove( 'updating-message' );
+ iframe.src = 'about:blank';
+ iframe.width = '0';
+ iframe.height = '0';
+ progressBar.value = 0;
+ currentBatchElement.textContent = '0';
+ currentTaskElement.textContent = '0';
+ totalTasksInBatchElement.textContent = '0';
+ statusContainer.style.display = 'none';
+ }
+ }
+ }
+
+ /**
+ * Prepares the next batch for processing.
+ */
+ async function prepareNextBatch() {
+ controlButton.textContent = __(
+ 'Getting next batch…',
+ 'optimization-detective'
+ );
+ currentBatch = await getBatch( cursor );
+
+ if ( ! currentBatch.urlGroups.length ) {
+ isNextBatchAvailable = false;
+ return;
+ }
+
+ currentBatchNumber++;
+ currentBatchElement.textContent = currentBatchNumber.toString();
+
+ // Initialize batch state.
+ verificationToken = currentBatch.verificationToken;
+ isDebug = currentBatch.isDebug;
+ currentTasks = flattenBatchToTasks( currentBatch.urlGroups );
+ currentTaskIndex = 0;
+
+ // Update UI for new batch.
+ progressBar.max = currentTasks.length;
+ progressBar.value = currentTaskIndex + 1;
+ totalTasksInBatchElement.textContent = currentTasks.length.toString();
+ currentTaskElement.textContent = ( currentTaskIndex + 1 ).toString();
+ controlButton.textContent = __( 'Pause', 'optimization-detective' );
+ }
+
+ /**
+ * Processes tasks in the current batch.
+ */
+ async function processCurrentBatch() {
+ while ( isProcessing && currentTaskIndex < currentTasks.length ) {
+ try {
+ await processTask(
+ currentTasks[ currentTaskIndex ],
+ abortController.signal
+ );
+ } catch ( error ) {
+ log( error.message );
+ if (
+ error.message.includes(
+ 'priming_mode_verification_token_expired'
+ )
+ ) {
+ verificationToken = await getVerificationToken();
+ } else if ( abortController.signal.aborted ) {
+ throw error;
+ }
+ }
+ currentTaskIndex++;
+ progressBar.value = currentTaskIndex + 1;
+ currentTaskElement.textContent = (
+ currentTaskIndex + 1
+ ).toString();
+ }
+ }
+
+ /**
+ * Flattens the url groups to tasks.
+ *
+ * @param {import("./types.ts").URLGroup[]} urlGroups - The url groups to flatten.
+ * @return {import("./types.ts").URLPrimingTask[]} - The flattened tasks.
+ */
+ function flattenBatchToTasks( urlGroups ) {
+ return urlGroups.flatMap( ( urlGroup ) =>
+ urlGroup.viewports.map( ( viewport ) => ( {
+ url: urlGroup.url,
+ width: viewport.width,
+ height: viewport.height,
+ } ) )
+ );
+ }
+
+ /**
+ * Fetches the next batch of URLs for metric priming.
+ *
+ * @param {?import("./types.ts").URLBatchCursor} lastCursor - The pagination cursor from the last batch or null for the first batch.
+ * @return {Promise} - Resolves with the next batch of URLs and metadata.
+ */
+
+ async function getBatch( lastCursor ) {
+ return await apiFetch( {
+ path: '/optimization-detective/v1/prime-urls',
+ method: 'POST',
+ data: { cursor: lastCursor },
+ } );
+ }
+
+ /**
+ * Fetches the verification token for priming mode.
+ *
+ * @return {Promise} - Resolves with the verification token.
+ */
+ async function getVerificationToken() {
+ return await apiFetch( {
+ path: '/optimization-detective/v1/priming-mode-verification-token',
+ method: 'GET',
+ } );
+ }
+
+ /**
+ * Loads the iframe and waits for the message.
+ *
+ * @param {import("./types.ts").URLPrimingTask} task - The viewport to set for the iframe.
+ * @param {AbortSignal} signal - The signal to abort the task.
+ * @return {Promise} The promise that resolves to void.
+ */
+ function processTask( task, signal ) {
+ return new Promise( ( resolve, reject ) => {
+ /**
+ * Handles the message from the iframe.
+ *
+ * @param {MessageEvent} event - The message event.
+ * @return {Promise} The promise that resolves to void.
+ */
+ async function handleMessage( event ) {
+ if (
+ event.data &&
+ event.data.type &&
+ 'OD_PRIME_URL_METRICS_REQUEST_STATUS' === event.data.type
+ ) {
+ if ( event.data.success ) {
+ await cleanup();
+ resolve();
+ } else {
+ await cleanup();
+ reject(
+ new Error(
+ event.data.error || 'URL Metric request failed'
+ )
+ );
+ }
+ }
+ }
+
+ /**
+ * Handles the aborting of the task on abort signal.
+ *
+ * @return {Promise} The promise that resolves to void.
+ */
+ async function abortHandler() {
+ await cleanup();
+ reject( new Error( 'Task Aborted' ) );
+ }
+
+ /**
+ * Cleans up the event listeners and iframe.
+ *
+ * @return {Promise} The promise that resolves to void.
+ */
+ function cleanup() {
+ return new Promise( ( cleanUpResolve ) => {
+ signal.removeEventListener( 'abort', abortHandler );
+ window.removeEventListener( 'message', handleMessage );
+ clearTimeout( timeoutId );
+ iframe.onerror = null;
+ iframe.src = 'about:blank';
+ iframe.addEventListener( 'load', () => cleanUpResolve(), {
+ once: true,
+ } );
+ } );
+ }
+
+ const timeoutId = setTimeout( async () => {
+ await cleanup();
+ reject( new Error( 'Timeout waiting for message' ) );
+ }, 30000 ); // 30-second timeout
+
+ if ( signal.aborted ) {
+ abortHandler();
+ return;
+ }
+
+ signal.addEventListener( 'abort', abortHandler );
+ window.addEventListener( 'message', handleMessage );
+
+ iframe.onerror = async () => {
+ await cleanup();
+ reject( new Error( 'Iframe failed to load' ) );
+ };
+
+ const url = new URL( task.url );
+ url.hash = `odPrimeModeVerificationToken=${ encodeURIComponent(
+ verificationToken
+ ) }&odPrimeModeSource=admin-dashboard`;
+
+ // Load the iframe.
+ iframe.src = url.toString();
+ iframe.width = task.width.toString();
+ iframe.height = task.height.toString();
+ } );
+ }
+
+ /**
+ * Fits the iframe to the container.
+ */
+ function fitIframe() {
+ if ( ! isDebug ) {
+ return;
+ }
+ const containerWidth = iframeContainer.clientWidth;
+ if ( containerWidth <= 0 ) {
+ return;
+ }
+
+ const nativeWidth = parseInt( iframe.width, 10 ) || 1;
+ const scale = containerWidth / nativeWidth;
+
+ iframe.style.position = 'unset';
+ iframe.style.transform = `scale(${ scale })`;
+ iframe.style.pointerEvents = 'auto';
+ iframe.style.opacity = '1';
+ iframe.style.zIndex = '9999';
+ }
+
+ /**
+ * Handles visibility change events to pause/resume processing when tab/window visibility changes.
+ */
+ function handleVisibilityChange() {
+ if ( 'hidden' === document.visibilityState ) {
+ if ( isProcessing ) {
+ isProcessing = false;
+ isTabHidden = true;
+ if ( ! abortController.signal.aborted ) {
+ abortController.abort();
+ }
+ controlButton.textContent = __(
+ 'Resume',
+ 'optimization-detective'
+ );
+ }
+ } else if ( 'visible' === document.visibilityState && isTabHidden ) {
+ isTabHidden = false;
+ if ( ! isProcessing ) {
+ isProcessing = true;
+ controlButton.textContent = __(
+ 'Pause',
+ 'optimization-detective'
+ );
+ if ( abortController.signal.aborted ) {
+ abortController = new AbortController();
+ }
+ processBatches();
+ }
+ }
+ }
+
+ /**
+ * Handler for the beforeunload event to prevent accidental page navigation.
+ *
+ * @param {BeforeUnloadEvent} event - The beforeunload event
+ */
+ function handleBeforeUnload( event ) {
+ if ( isProcessing ) {
+ event.preventDefault();
+ }
+ }
+
+ // Attach event listeners.
+ controlButton.addEventListener( 'click', toggleProcessing );
+ document.addEventListener( 'visibilitychange', handleVisibilityChange );
+ window.addEventListener( 'beforeunload', handleBeforeUnload );
+ window.addEventListener( 'resize', fitIframe );
+} )();
diff --git a/plugins/optimization-detective/priming-cli/index.js b/plugins/optimization-detective/priming-cli/index.js
new file mode 100644
index 0000000000..7b2ca44440
--- /dev/null
+++ b/plugins/optimization-detective/priming-cli/index.js
@@ -0,0 +1,400 @@
+#!/usr/bin/env node
+
+import { launch, Page } from 'puppeteer';
+import { program } from 'commander';
+import { execSync } from 'child_process';
+import ora from 'ora';
+import chalk from 'chalk';
+
+program
+ .name( 'od-prime' )
+ .description( 'CLI tool to prime URL metrics for Optimization Detective' )
+ .parse( process.argv );
+
+/**
+ * Instance of ora spinner for displaying status messages.
+ *
+ * @see {init}
+ * @see {getBatch}
+ * @see {checkEnvironment}
+ * @type {import('ora').Ora}
+ */
+const spinner = ora( 'Starting...' ).start();
+
+/**
+ * Instance of AbortController to handle aborting tasks.
+ *
+ * @see {getBatch}
+ * @type {AbortController}
+ */
+const abortController = new AbortController();
+
+/**
+ * Abort signal to be used to detect abort events.
+ *
+ * @see {processTask}
+ * @type {AbortSignal}
+ */
+const signal = abortController.signal;
+
+// Listen for the SIGINT signal (Ctrl+C) to abort the process.
+process.on( 'SIGINT', () => {
+ spinner.start( 'Aborting...' );
+ abortController.abort();
+} );
+
+/**
+ * Checks the environment for required tools and plugins.
+ *
+ * @return {boolean} - True if all checks passed, false otherwise.
+ */
+function checkEnvironment() {
+ const checks = [
+ {
+ name: 'WP CLI Availability',
+ command: 'wp --info',
+ errorMessage:
+ 'WP CLI is not installed. Please install WP CLI and try again.',
+ },
+ {
+ name: 'WordPress Availability',
+ command: 'wp core is-installed',
+ errorMessage:
+ 'WordPress is not installed or not accessible in this context.',
+ },
+ {
+ name: 'Optimization Detective WP_CLI command',
+ command: 'wp help od priming-mode get-url-batch',
+ errorMessage:
+ 'Optimization Detective plugin is not installed or activated. Please install and activate the plugin.',
+ },
+ ];
+
+ for ( const check of checks ) {
+ try {
+ execSync( check.command, { stdio: 'ignore' } );
+ } catch ( error ) {
+ spinner.fail(
+ chalk.red(
+ `${ check.name } check failed: ${ check.errorMessage }`
+ )
+ );
+ return false;
+ }
+ }
+ return true;
+}
+
+/**
+ * Fetches the next batch of URLs.
+ *
+ * @param {?import("./types.ts").URLBatchCursor} lastCursor - The cursor to fetch the next batch.
+ * @return {?import("./types.ts").URLBatchResponse} - The batch of URLs.
+ */
+function getBatch( lastCursor ) {
+ try {
+ let command = 'wp od priming-mode get-url-batch --format=json';
+
+ if ( lastCursor ) {
+ command += ` --provider-index=${ lastCursor.provider_index || 0 }`;
+ command += ` --subtype-index=${ lastCursor.subtype_index || 0 }`;
+ command += ` --page-number=${ lastCursor.page_number || 0 }`;
+ command += ` --offset-within-page=${
+ lastCursor.offset_within_page || 0
+ }`;
+ command += ` --batch-size=${ lastCursor.batch_size || 10 }`;
+ }
+
+ const batchOutput = execSync( command ).toString();
+ const parsedBatch = JSON.parse( batchOutput );
+
+ if ( ! parsedBatch || parsedBatch.length === 0 ) {
+ throw new Error( 'Invalid batch data received.' );
+ }
+ return parsedBatch[ 0 ];
+ } catch ( error ) {
+ spinner.fail( 'Error occurred while fetching batch: ' + error.message );
+ abortController.abort();
+ return null;
+ }
+}
+
+/**
+ * Fetches the verification token.
+ *
+ * @return {string|null} - The verification token or null if not available.
+ */
+function getVerificationToken() {
+ try {
+ const verificationToken = execSync(
+ 'wp od priming-mode get-verification-token'
+ )
+ .toString()
+ .trim();
+ if ( '' === verificationToken ) {
+ throw new Error( 'Invalid verification token received.' );
+ }
+ return verificationToken;
+ } catch ( error ) {
+ spinner.fail(
+ 'Error occurred while fetching verification token: ' + error.message
+ );
+ abortController.abort();
+ return null;
+ }
+}
+
+/**
+ * Flattens the url groups to tasks.
+ *
+ * @param {import("./types.ts").URLGroup[]} urlGroups - The url groups to flatten.
+ * @return {import("./types.ts").URLPrimingTask[]} - The flattened tasks.
+ */
+function flattenBatchToTasks( urlGroups ) {
+ return urlGroups.flatMap( ( urlGroup ) =>
+ urlGroup.viewports.map( ( viewport ) => ( {
+ url: urlGroup.url,
+ width: viewport.width,
+ height: viewport.height,
+ } ) )
+ );
+}
+
+/**
+ * Processes a single task using Puppeteer.
+ *
+ * @param {Page} page - The Puppeteer page to use.
+ * @param {import("./types.ts").URLPrimingTask} task - The task parameters.
+ * @param {string} verificationToken - The verification token.
+ * @param {AbortSignal} abortSignal - The abort signal.
+ * @return {Promise}
+ */
+async function processTask( page, task, verificationToken, abortSignal ) {
+ return new Promise( async ( resolve, reject ) => {
+ /**
+ * Handles the abort event.
+ */
+ function onAbort() {
+ page.evaluate( () => {
+ window.dispatchEvent(
+ new CustomEvent( 'OD_PRIME_URL_METRICS_REQUEST_STATUS', {
+ detail: {
+ success: false,
+ error: 'Task aborted.',
+ },
+ } )
+ );
+ } );
+ }
+ abortSignal.addEventListener( 'abort', onAbort );
+
+ /**
+ * Cleans up the page and abort signal listeners.
+ *
+ * @return {Promise} The promise that resolves to void.
+ */
+ async function cleanup() {
+ abortSignal.removeEventListener( 'abort', onAbort );
+ await page.goto( 'about:blank', {
+ waitUntil: 'load',
+ timeout: 30000,
+ signal: abortSignal,
+ } );
+ }
+
+ try {
+ await page.setViewport( {
+ width: task.width,
+ height: task.height,
+ } );
+
+ const url = new URL( task.url );
+ url.hash = `odPrimeModeVerificationToken=${ encodeURIComponent(
+ verificationToken
+ ) }&odPrimeModeSource=priming-cli`;
+
+ await page.goto( url.toString(), {
+ waitUntil: 'load',
+ timeout: 30000,
+ signal: abortSignal,
+ } );
+
+ await page.evaluate( () => {
+ return new Promise(
+ ( requestSuccessResolve, requestSuccessReject ) => {
+ // Set timeout for 30 seconds.
+ const timeoutId = setTimeout( () => {
+ requestSuccessReject(
+ new Error(
+ 'Timed out waiting for event "OD_PRIME_URL_METRICS_REQUEST_SUCCESS".'
+ )
+ );
+ }, 30000 );
+
+ /**
+ * Handles the message from the page.
+ *
+ * @param {CustomEvent} event - The message event.
+ */
+ function handleMessage( event ) {
+ if ( event.detail && event.detail.success ) {
+ clearTimeout( timeoutId );
+ requestSuccessResolve();
+ } else {
+ clearTimeout( timeoutId );
+ requestSuccessReject(
+ new Error(
+ event.detail.error ||
+ 'URL Metric request failed'
+ )
+ );
+ }
+ }
+
+ document.addEventListener(
+ 'OD_PRIME_URL_METRICS_REQUEST_STATUS',
+ handleMessage,
+ { once: true }
+ );
+ }
+ );
+ } );
+
+ await cleanup();
+ resolve();
+ } catch ( error ) {
+ await cleanup();
+ reject( error );
+ }
+ } );
+}
+
+/**
+ * Init function to process all batches.
+ * @return {Promise}
+ */
+async function init() {
+ /**
+ * Puppeteer browser instance used for headless page navigation and rendering.
+ *
+ * @type {import('puppeteer').Browser}
+ */
+ const browser = await launch( { headless: true } );
+
+ /**
+ * Main Puppeteer page object used to navigate to URLs.
+ *
+ * @type {import('puppeteer').Page}
+ */
+ const browserPage = await browser.newPage();
+
+ /**
+ * Flag indicating whether more URL batches are available for processing.
+ *
+ * @type {boolean}
+ */
+ let isNextBatchAvailable = true;
+
+ /**
+ * Cursor object to track position in pagination when fetching URL batches.
+ *
+ * @type {import("./types.ts").URLBatchCursor}
+ */
+ let cursor = null;
+
+ /**
+ * Counter tracking the number of URL batches processed so far.
+ *
+ * @type {number}
+ */
+ let currentBatchNumber = 0;
+
+ /**
+ * Token used to verify REST API requests server side when in priming mode.
+ *
+ * @type {string}
+ */
+ let verificationToken = null;
+
+ // Process batches until no more are available.
+ while ( isNextBatchAvailable ) {
+ if ( signal.aborted ) {
+ break;
+ }
+ spinner.start( 'Fetching next batch' );
+ const currentBatch = getBatch( cursor );
+
+ // If no URLs remain in the batch, finish processing.
+ if (
+ null === currentBatch ||
+ ! currentBatch.urlGroups ||
+ currentBatch.urlGroups.length === 0
+ ) {
+ isNextBatchAvailable = false;
+ break;
+ }
+ verificationToken = currentBatch.verificationToken;
+ currentBatchNumber++;
+
+ spinner.text = `Batch ${ currentBatchNumber } fetched successfully.`;
+
+ const currentTasks = flattenBatchToTasks( currentBatch.urlGroups );
+
+ // Process each task sequentially.
+ for ( let i = 0; i < currentTasks.length; i++ ) {
+ if ( signal.aborted ) {
+ break;
+ }
+ const task = currentTasks[ i ];
+
+ spinner.start(
+ `Processing batch ${ chalk.green(
+ currentBatchNumber
+ ) } task ${ chalk.green(
+ i + 1 + '/' + currentTasks.length
+ ) } for ${ chalk.blue( task.url ) } at ${ chalk.blue(
+ task.width + 'x' + task.height
+ ) }`
+ );
+ try {
+ await processTask(
+ browserPage,
+ task,
+ verificationToken,
+ signal
+ );
+ } catch ( error ) {
+ // Refresh verification token if expired.
+ if (
+ error.message.includes(
+ 'priming_mode_verification_token_expired'
+ )
+ ) {
+ verificationToken = getVerificationToken();
+ i--;
+ } else {
+ // Log the error and continue processing the next task.
+ spinner.fail(
+ `Error processing task ${ i + 1 }. Error: ${
+ error.message
+ }`
+ );
+ }
+ }
+ }
+ cursor = currentBatch.cursor;
+ }
+
+ await browser.close();
+
+ if ( signal.aborted ) {
+ spinner.fail( chalk.red( 'Aborted.' ) );
+ } else {
+ spinner.succeed( chalk.green( 'All tasks completed.' ) );
+ }
+}
+
+// Start the process.
+if ( checkEnvironment() ) {
+ init();
+}
diff --git a/plugins/optimization-detective/priming-cli/package-lock.json b/plugins/optimization-detective/priming-cli/package-lock.json
new file mode 100644
index 0000000000..b3f717ddd1
--- /dev/null
+++ b/plugins/optimization-detective/priming-cli/package-lock.json
@@ -0,0 +1,1459 @@
+{
+ "name": "@wordpress/performance-od-url-metric-collector",
+ "version": "1.0.0",
+ "lockfileVersion": 3,
+ "requires": true,
+ "packages": {
+ "": {
+ "name": "@wordpress/performance-od-url-metric-collector",
+ "version": "1.0.0",
+ "license": "GPL-2.0-or-later",
+ "dependencies": {
+ "chalk": "^5.4.1",
+ "commander": "^13.1.0",
+ "ora": "^8.2.0",
+ "puppeteer": "^24.4.0"
+ },
+ "bin": {
+ "od-prime": "index.js"
+ },
+ "devDependencies": {
+ "typescript": "^5.8.2"
+ }
+ },
+ "node_modules/@babel/code-frame": {
+ "version": "7.26.2",
+ "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.26.2.tgz",
+ "integrity": "sha512-RJlIHRueQgwWitWgF8OdFYGZX328Ax5BCemNGlqHfplnRT9ESi8JkFlvaVYbS+UubVY6dpv87Fs2u5M29iNFVQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-validator-identifier": "^7.25.9",
+ "js-tokens": "^4.0.0",
+ "picocolors": "^1.0.0"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/helper-validator-identifier": {
+ "version": "7.25.9",
+ "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.25.9.tgz",
+ "integrity": "sha512-Ed61U6XJc3CVRfkERJWDz4dJwKe7iLmmJsbOGu9wSloNSFttHV0I8g6UAgb7qnK5ly5bGLPd4oXZlxCdANBOWQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@puppeteer/browsers": {
+ "version": "2.9.0",
+ "resolved": "https://registry.npmjs.org/@puppeteer/browsers/-/browsers-2.9.0.tgz",
+ "integrity": "sha512-8+xM+cFydYET4X/5/3yZMHs7sjS6c9I6H5I3xJdb6cinzxWUT/I2QVw4avxCQ8QDndwdHkG/FiSZIrCjAbaKvQ==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "debug": "^4.4.0",
+ "extract-zip": "^2.0.1",
+ "progress": "^2.0.3",
+ "proxy-agent": "^6.5.0",
+ "semver": "^7.7.1",
+ "tar-fs": "^3.0.8",
+ "yargs": "^17.7.2"
+ },
+ "bin": {
+ "browsers": "lib/cjs/main-cli.js"
+ },
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@tootallnate/quickjs-emscripten": {
+ "version": "0.23.0",
+ "resolved": "https://registry.npmjs.org/@tootallnate/quickjs-emscripten/-/quickjs-emscripten-0.23.0.tgz",
+ "integrity": "sha512-C5Mc6rdnsaJDjO3UpGW/CQTHtCKaYlScZTly4JIu97Jxo/odCiH0ITnDXSJPTOrEKk/ycSZ0AOgTmkDtkOsvIA==",
+ "license": "MIT"
+ },
+ "node_modules/@types/node": {
+ "version": "22.14.0",
+ "resolved": "https://registry.npmjs.org/@types/node/-/node-22.14.0.tgz",
+ "integrity": "sha512-Kmpl+z84ILoG+3T/zQFyAJsU6EPTmOCj8/2+83fSN6djd6I4o7uOuGIH6vq3PrjY5BGitSbFuMN18j3iknubbA==",
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "undici-types": "~6.21.0"
+ }
+ },
+ "node_modules/@types/yauzl": {
+ "version": "2.10.3",
+ "resolved": "https://registry.npmjs.org/@types/yauzl/-/yauzl-2.10.3.tgz",
+ "integrity": "sha512-oJoftv0LSuaDZE3Le4DbKX+KS9G36NzOeSap90UIK0yMA/NhKJhqlSGtNDORNRaIbQfzjXDrQa0ytJ6mNRGz/Q==",
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "@types/node": "*"
+ }
+ },
+ "node_modules/agent-base": {
+ "version": "7.1.3",
+ "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.3.tgz",
+ "integrity": "sha512-jRR5wdylq8CkOe6hei19GGZnxM6rBGwFl3Bg0YItGDimvjGtAvdZk4Pu6Cl4u4Igsws4a1fd1Vq3ezrhn4KmFw==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 14"
+ }
+ },
+ "node_modules/ansi-regex": {
+ "version": "6.1.0",
+ "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.1.0.tgz",
+ "integrity": "sha512-7HSX4QQb4CspciLpVFwyRe79O3xsIZDDLER21kERQ71oaPodF8jL725AgJMFAYbooIqolJoRLuM81SpeUkpkvA==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://github.com/chalk/ansi-regex?sponsor=1"
+ }
+ },
+ "node_modules/ansi-styles": {
+ "version": "4.3.0",
+ "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
+ "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
+ "license": "MIT",
+ "dependencies": {
+ "color-convert": "^2.0.1"
+ },
+ "engines": {
+ "node": ">=8"
+ },
+ "funding": {
+ "url": "https://github.com/chalk/ansi-styles?sponsor=1"
+ }
+ },
+ "node_modules/argparse": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz",
+ "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==",
+ "license": "Python-2.0"
+ },
+ "node_modules/ast-types": {
+ "version": "0.13.4",
+ "resolved": "https://registry.npmjs.org/ast-types/-/ast-types-0.13.4.tgz",
+ "integrity": "sha512-x1FCFnFifvYDDzTaLII71vG5uvDwgtmDTEVWAxrgeiR8VjMONcCXJx7E+USjDtHlwFmt9MysbqgF9b9Vjr6w+w==",
+ "license": "MIT",
+ "dependencies": {
+ "tslib": "^2.0.1"
+ },
+ "engines": {
+ "node": ">=4"
+ }
+ },
+ "node_modules/b4a": {
+ "version": "1.6.7",
+ "resolved": "https://registry.npmjs.org/b4a/-/b4a-1.6.7.tgz",
+ "integrity": "sha512-OnAYlL5b7LEkALw87fUVafQw5rVR9RjwGd4KUwNQ6DrrNmaVaUCgLipfVlzrPQ4tWOR9P0IXGNOx50jYCCdSJg==",
+ "license": "Apache-2.0"
+ },
+ "node_modules/bare-events": {
+ "version": "2.5.4",
+ "resolved": "https://registry.npmjs.org/bare-events/-/bare-events-2.5.4.tgz",
+ "integrity": "sha512-+gFfDkR8pj4/TrWCGUGWmJIkBwuxPS5F+a5yWjOHQt2hHvNZd5YLzadjmDUtFmMM4y429bnKLa8bYBMHcYdnQA==",
+ "license": "Apache-2.0",
+ "optional": true
+ },
+ "node_modules/bare-fs": {
+ "version": "4.0.2",
+ "resolved": "https://registry.npmjs.org/bare-fs/-/bare-fs-4.0.2.tgz",
+ "integrity": "sha512-S5mmkMesiduMqnz51Bfh0Et9EX0aTCJxhsI4bvzFFLs8Z1AV8RDHadfY5CyLwdoLHgXbNBEN1gQcbEtGwuvixw==",
+ "license": "Apache-2.0",
+ "optional": true,
+ "dependencies": {
+ "bare-events": "^2.5.4",
+ "bare-path": "^3.0.0",
+ "bare-stream": "^2.6.4"
+ },
+ "engines": {
+ "bare": ">=1.16.0"
+ },
+ "peerDependencies": {
+ "bare-buffer": "*"
+ },
+ "peerDependenciesMeta": {
+ "bare-buffer": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/bare-os": {
+ "version": "3.6.1",
+ "resolved": "https://registry.npmjs.org/bare-os/-/bare-os-3.6.1.tgz",
+ "integrity": "sha512-uaIjxokhFidJP+bmmvKSgiMzj2sV5GPHaZVAIktcxcpCyBFFWO+YlikVAdhmUo2vYFvFhOXIAlldqV29L8126g==",
+ "license": "Apache-2.0",
+ "optional": true,
+ "engines": {
+ "bare": ">=1.14.0"
+ }
+ },
+ "node_modules/bare-path": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/bare-path/-/bare-path-3.0.0.tgz",
+ "integrity": "sha512-tyfW2cQcB5NN8Saijrhqn0Zh7AnFNsnczRcuWODH0eYAXBsJ5gVxAUuNr7tsHSC6IZ77cA0SitzT+s47kot8Mw==",
+ "license": "Apache-2.0",
+ "optional": true,
+ "dependencies": {
+ "bare-os": "^3.0.1"
+ }
+ },
+ "node_modules/bare-stream": {
+ "version": "2.6.5",
+ "resolved": "https://registry.npmjs.org/bare-stream/-/bare-stream-2.6.5.tgz",
+ "integrity": "sha512-jSmxKJNJmHySi6hC42zlZnq00rga4jjxcgNZjY9N5WlOe/iOoGRtdwGsHzQv2RlH2KOYMwGUXhf2zXd32BA9RA==",
+ "license": "Apache-2.0",
+ "optional": true,
+ "dependencies": {
+ "streamx": "^2.21.0"
+ },
+ "peerDependencies": {
+ "bare-buffer": "*",
+ "bare-events": "*"
+ },
+ "peerDependenciesMeta": {
+ "bare-buffer": {
+ "optional": true
+ },
+ "bare-events": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/basic-ftp": {
+ "version": "5.0.5",
+ "resolved": "https://registry.npmjs.org/basic-ftp/-/basic-ftp-5.0.5.tgz",
+ "integrity": "sha512-4Bcg1P8xhUuqcii/S0Z9wiHIrQVPMermM1any+MX5GeGD7faD3/msQUDGLol9wOcz4/jbg/WJnGqoJF6LiBdtg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=10.0.0"
+ }
+ },
+ "node_modules/buffer-crc32": {
+ "version": "0.2.13",
+ "resolved": "https://registry.npmjs.org/buffer-crc32/-/buffer-crc32-0.2.13.tgz",
+ "integrity": "sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ==",
+ "license": "MIT",
+ "engines": {
+ "node": "*"
+ }
+ },
+ "node_modules/callsites": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz",
+ "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/chalk": {
+ "version": "5.4.1",
+ "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.4.1.tgz",
+ "integrity": "sha512-zgVZuo2WcZgfUEmsn6eO3kINexW8RAE4maiQ8QNs8CtpPCSyMiYsULR3HQYkm3w8FIA3SberyMJMSldGsW+U3w==",
+ "license": "MIT",
+ "engines": {
+ "node": "^12.17.0 || ^14.13 || >=16.0.0"
+ },
+ "funding": {
+ "url": "https://github.com/chalk/chalk?sponsor=1"
+ }
+ },
+ "node_modules/chromium-bidi": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/chromium-bidi/-/chromium-bidi-3.0.0.tgz",
+ "integrity": "sha512-ZOGRDAhBMX1uxL2Cm2TDuhImbrsEz5A/tTcVU6RpXEWaTNUNwsHW6njUXizh51Ir6iqHbKAfhA2XK33uBcLo5A==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "mitt": "^3.0.1",
+ "zod": "^3.24.1"
+ },
+ "peerDependencies": {
+ "devtools-protocol": "*"
+ }
+ },
+ "node_modules/cli-cursor": {
+ "version": "5.0.0",
+ "resolved": "https://registry.npmjs.org/cli-cursor/-/cli-cursor-5.0.0.tgz",
+ "integrity": "sha512-aCj4O5wKyszjMmDT4tZj93kxyydN/K5zPWSCe6/0AV/AA1pqe5ZBIw0a2ZfPQV7lL5/yb5HsUreJ6UFAF1tEQw==",
+ "license": "MIT",
+ "dependencies": {
+ "restore-cursor": "^5.0.0"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/cli-spinners": {
+ "version": "2.9.2",
+ "resolved": "https://registry.npmjs.org/cli-spinners/-/cli-spinners-2.9.2.tgz",
+ "integrity": "sha512-ywqV+5MmyL4E7ybXgKys4DugZbX0FC6LnwrhjuykIjnK9k8OQacQ7axGKnjDXWNhns0xot3bZI5h55H8yo9cJg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=6"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/cliui": {
+ "version": "8.0.1",
+ "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz",
+ "integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==",
+ "license": "ISC",
+ "dependencies": {
+ "string-width": "^4.2.0",
+ "strip-ansi": "^6.0.1",
+ "wrap-ansi": "^7.0.0"
+ },
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/cliui/node_modules/ansi-regex": {
+ "version": "5.0.1",
+ "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
+ "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/cliui/node_modules/emoji-regex": {
+ "version": "8.0.0",
+ "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
+ "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
+ "license": "MIT"
+ },
+ "node_modules/cliui/node_modules/string-width": {
+ "version": "4.2.3",
+ "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
+ "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
+ "license": "MIT",
+ "dependencies": {
+ "emoji-regex": "^8.0.0",
+ "is-fullwidth-code-point": "^3.0.0",
+ "strip-ansi": "^6.0.1"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/cliui/node_modules/strip-ansi": {
+ "version": "6.0.1",
+ "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
+ "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
+ "license": "MIT",
+ "dependencies": {
+ "ansi-regex": "^5.0.1"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/color-convert": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
+ "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
+ "license": "MIT",
+ "dependencies": {
+ "color-name": "~1.1.4"
+ },
+ "engines": {
+ "node": ">=7.0.0"
+ }
+ },
+ "node_modules/color-name": {
+ "version": "1.1.4",
+ "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz",
+ "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
+ "license": "MIT"
+ },
+ "node_modules/commander": {
+ "version": "13.1.0",
+ "resolved": "https://registry.npmjs.org/commander/-/commander-13.1.0.tgz",
+ "integrity": "sha512-/rFeCpNJQbhSZjGVwO9RFV3xPqbnERS8MmIQzCtD/zl6gpJuV/bMLuN92oG3F7d8oDEHHRrujSXNUr8fpjntKw==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/cosmiconfig": {
+ "version": "9.0.0",
+ "resolved": "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-9.0.0.tgz",
+ "integrity": "sha512-itvL5h8RETACmOTFc4UfIyB2RfEHi71Ax6E/PivVxq9NseKbOWpeyHEOIbmAw1rs8Ak0VursQNww7lf7YtUwzg==",
+ "license": "MIT",
+ "dependencies": {
+ "env-paths": "^2.2.1",
+ "import-fresh": "^3.3.0",
+ "js-yaml": "^4.1.0",
+ "parse-json": "^5.2.0"
+ },
+ "engines": {
+ "node": ">=14"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/d-fischer"
+ },
+ "peerDependencies": {
+ "typescript": ">=4.9.5"
+ },
+ "peerDependenciesMeta": {
+ "typescript": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/data-uri-to-buffer": {
+ "version": "6.0.2",
+ "resolved": "https://registry.npmjs.org/data-uri-to-buffer/-/data-uri-to-buffer-6.0.2.tgz",
+ "integrity": "sha512-7hvf7/GW8e86rW0ptuwS3OcBGDjIi6SZva7hCyWC0yYry2cOPmLIjXAUHI6DK2HsnwJd9ifmt57i8eV2n4YNpw==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 14"
+ }
+ },
+ "node_modules/debug": {
+ "version": "4.4.0",
+ "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.0.tgz",
+ "integrity": "sha512-6WTZ/IxCY/T6BALoZHaE4ctp9xm+Z5kY/pzYaCHRFeyVhojxlrm+46y68HA6hr0TcwEssoxNiDEUJQjfPZ/RYA==",
+ "license": "MIT",
+ "dependencies": {
+ "ms": "^2.1.3"
+ },
+ "engines": {
+ "node": ">=6.0"
+ },
+ "peerDependenciesMeta": {
+ "supports-color": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/degenerator": {
+ "version": "5.0.1",
+ "resolved": "https://registry.npmjs.org/degenerator/-/degenerator-5.0.1.tgz",
+ "integrity": "sha512-TllpMR/t0M5sqCXfj85i4XaAzxmS5tVA16dqvdkMwGmzI+dXLXnw3J+3Vdv7VKw+ThlTMboK6i9rnZ6Nntj5CQ==",
+ "license": "MIT",
+ "dependencies": {
+ "ast-types": "^0.13.4",
+ "escodegen": "^2.1.0",
+ "esprima": "^4.0.1"
+ },
+ "engines": {
+ "node": ">= 14"
+ }
+ },
+ "node_modules/devtools-protocol": {
+ "version": "0.0.1413902",
+ "resolved": "https://registry.npmjs.org/devtools-protocol/-/devtools-protocol-0.0.1413902.tgz",
+ "integrity": "sha512-yRtvFD8Oyk7C9Os3GmnFZLu53yAfsnyw1s+mLmHHUK0GQEc9zthHWvS1r67Zqzm5t7v56PILHIVZ7kmFMaL2yQ==",
+ "license": "BSD-3-Clause"
+ },
+ "node_modules/emoji-regex": {
+ "version": "10.4.0",
+ "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.4.0.tgz",
+ "integrity": "sha512-EC+0oUMY1Rqm4O6LLrgjtYDvcVYTy7chDnM4Q7030tP4Kwj3u/pR6gP9ygnp2CJMK5Gq+9Q2oqmrFJAz01DXjw==",
+ "license": "MIT"
+ },
+ "node_modules/end-of-stream": {
+ "version": "1.4.4",
+ "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.4.tgz",
+ "integrity": "sha512-+uw1inIHVPQoaVuHzRyXd21icM+cnt4CzD5rW+NC1wjOUSTOs+Te7FOv7AhN7vS9x/oIyhLP5PR1H+phQAHu5Q==",
+ "license": "MIT",
+ "dependencies": {
+ "once": "^1.4.0"
+ }
+ },
+ "node_modules/env-paths": {
+ "version": "2.2.1",
+ "resolved": "https://registry.npmjs.org/env-paths/-/env-paths-2.2.1.tgz",
+ "integrity": "sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/error-ex": {
+ "version": "1.3.2",
+ "resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.2.tgz",
+ "integrity": "sha512-7dFHNmqeFSEt2ZBsCriorKnn3Z2pj+fd9kmI6QoWw4//DL+icEBfc0U7qJCisqrTsKTjw4fNFy2pW9OqStD84g==",
+ "license": "MIT",
+ "dependencies": {
+ "is-arrayish": "^0.2.1"
+ }
+ },
+ "node_modules/escalade": {
+ "version": "3.2.0",
+ "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz",
+ "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/escodegen": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/escodegen/-/escodegen-2.1.0.tgz",
+ "integrity": "sha512-2NlIDTwUWJN0mRPQOdtQBzbUHvdGY2P1VXSyU83Q3xKxM7WHX2Ql8dKq782Q9TgQUNOLEzEYu9bzLNj1q88I5w==",
+ "license": "BSD-2-Clause",
+ "dependencies": {
+ "esprima": "^4.0.1",
+ "estraverse": "^5.2.0",
+ "esutils": "^2.0.2"
+ },
+ "bin": {
+ "escodegen": "bin/escodegen.js",
+ "esgenerate": "bin/esgenerate.js"
+ },
+ "engines": {
+ "node": ">=6.0"
+ },
+ "optionalDependencies": {
+ "source-map": "~0.6.1"
+ }
+ },
+ "node_modules/esprima": {
+ "version": "4.0.1",
+ "resolved": "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz",
+ "integrity": "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==",
+ "license": "BSD-2-Clause",
+ "bin": {
+ "esparse": "bin/esparse.js",
+ "esvalidate": "bin/esvalidate.js"
+ },
+ "engines": {
+ "node": ">=4"
+ }
+ },
+ "node_modules/estraverse": {
+ "version": "5.3.0",
+ "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz",
+ "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==",
+ "license": "BSD-2-Clause",
+ "engines": {
+ "node": ">=4.0"
+ }
+ },
+ "node_modules/esutils": {
+ "version": "2.0.3",
+ "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz",
+ "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==",
+ "license": "BSD-2-Clause",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/extract-zip": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/extract-zip/-/extract-zip-2.0.1.tgz",
+ "integrity": "sha512-GDhU9ntwuKyGXdZBUgTIe+vXnWj0fppUEtMDL0+idd5Sta8TGpHssn/eusA9mrPr9qNDym6SxAYZjNvCn/9RBg==",
+ "license": "BSD-2-Clause",
+ "dependencies": {
+ "debug": "^4.1.1",
+ "get-stream": "^5.1.0",
+ "yauzl": "^2.10.0"
+ },
+ "bin": {
+ "extract-zip": "cli.js"
+ },
+ "engines": {
+ "node": ">= 10.17.0"
+ },
+ "optionalDependencies": {
+ "@types/yauzl": "^2.9.1"
+ }
+ },
+ "node_modules/fast-fifo": {
+ "version": "1.3.2",
+ "resolved": "https://registry.npmjs.org/fast-fifo/-/fast-fifo-1.3.2.tgz",
+ "integrity": "sha512-/d9sfos4yxzpwkDkuN7k2SqFKtYNmCTzgfEpz82x34IM9/zc8KGxQoXg1liNC/izpRM/MBdt44Nmx41ZWqk+FQ==",
+ "license": "MIT"
+ },
+ "node_modules/fd-slicer": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/fd-slicer/-/fd-slicer-1.1.0.tgz",
+ "integrity": "sha512-cE1qsB/VwyQozZ+q1dGxR8LBYNZeofhEdUNGSMbQD3Gw2lAzX9Zb3uIU6Ebc/Fmyjo9AWWfnn0AUCHqtevs/8g==",
+ "license": "MIT",
+ "dependencies": {
+ "pend": "~1.2.0"
+ }
+ },
+ "node_modules/get-caller-file": {
+ "version": "2.0.5",
+ "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz",
+ "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==",
+ "license": "ISC",
+ "engines": {
+ "node": "6.* || 8.* || >= 10.*"
+ }
+ },
+ "node_modules/get-east-asian-width": {
+ "version": "1.3.0",
+ "resolved": "https://registry.npmjs.org/get-east-asian-width/-/get-east-asian-width-1.3.0.tgz",
+ "integrity": "sha512-vpeMIQKxczTD/0s2CdEWHcb0eeJe6TFjxb+J5xgX7hScxqrGuyjmv4c1D4A/gelKfyox0gJJwIHF+fLjeaM8kQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/get-stream": {
+ "version": "5.2.0",
+ "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-5.2.0.tgz",
+ "integrity": "sha512-nBF+F1rAZVCu/p7rjzgA+Yb4lfYXrpl7a6VmJrU8wF9I1CKvP/QwPNZHnOlwbTkY6dvtFIzFMSyQXbLoTQPRpA==",
+ "license": "MIT",
+ "dependencies": {
+ "pump": "^3.0.0"
+ },
+ "engines": {
+ "node": ">=8"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/get-uri": {
+ "version": "6.0.4",
+ "resolved": "https://registry.npmjs.org/get-uri/-/get-uri-6.0.4.tgz",
+ "integrity": "sha512-E1b1lFFLvLgak2whF2xDBcOy6NLVGZBqqjJjsIhvopKfWWEi64pLVTWWehV8KlLerZkfNTA95sTe2OdJKm1OzQ==",
+ "license": "MIT",
+ "dependencies": {
+ "basic-ftp": "^5.0.2",
+ "data-uri-to-buffer": "^6.0.2",
+ "debug": "^4.3.4"
+ },
+ "engines": {
+ "node": ">= 14"
+ }
+ },
+ "node_modules/http-proxy-agent": {
+ "version": "7.0.2",
+ "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz",
+ "integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==",
+ "license": "MIT",
+ "dependencies": {
+ "agent-base": "^7.1.0",
+ "debug": "^4.3.4"
+ },
+ "engines": {
+ "node": ">= 14"
+ }
+ },
+ "node_modules/https-proxy-agent": {
+ "version": "7.0.6",
+ "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz",
+ "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==",
+ "license": "MIT",
+ "dependencies": {
+ "agent-base": "^7.1.2",
+ "debug": "4"
+ },
+ "engines": {
+ "node": ">= 14"
+ }
+ },
+ "node_modules/import-fresh": {
+ "version": "3.3.1",
+ "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz",
+ "integrity": "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==",
+ "license": "MIT",
+ "dependencies": {
+ "parent-module": "^1.0.0",
+ "resolve-from": "^4.0.0"
+ },
+ "engines": {
+ "node": ">=6"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/ip-address": {
+ "version": "9.0.5",
+ "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-9.0.5.tgz",
+ "integrity": "sha512-zHtQzGojZXTwZTHQqra+ETKd4Sn3vgi7uBmlPoXVWZqYvuKmtI0l/VZTjqGmJY9x88GGOaZ9+G9ES8hC4T4X8g==",
+ "license": "MIT",
+ "dependencies": {
+ "jsbn": "1.1.0",
+ "sprintf-js": "^1.1.3"
+ },
+ "engines": {
+ "node": ">= 12"
+ }
+ },
+ "node_modules/is-arrayish": {
+ "version": "0.2.1",
+ "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.2.1.tgz",
+ "integrity": "sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==",
+ "license": "MIT"
+ },
+ "node_modules/is-fullwidth-code-point": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz",
+ "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/is-interactive": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/is-interactive/-/is-interactive-2.0.0.tgz",
+ "integrity": "sha512-qP1vozQRI+BMOPcjFzrjXuQvdak2pHNUMZoeG2eRbiSqyvbEf/wQtEOTOX1guk6E3t36RkaqiSt8A/6YElNxLQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/is-unicode-supported": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/is-unicode-supported/-/is-unicode-supported-2.1.0.tgz",
+ "integrity": "sha512-mE00Gnza5EEB3Ds0HfMyllZzbBrmLOX3vfWoj9A9PEnTfratQ/BcaJOuMhnkhjXvb2+FkY3VuHqtAGpTPmglFQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/js-tokens": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
+ "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==",
+ "license": "MIT"
+ },
+ "node_modules/js-yaml": {
+ "version": "4.1.0",
+ "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz",
+ "integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==",
+ "license": "MIT",
+ "dependencies": {
+ "argparse": "^2.0.1"
+ },
+ "bin": {
+ "js-yaml": "bin/js-yaml.js"
+ }
+ },
+ "node_modules/jsbn": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/jsbn/-/jsbn-1.1.0.tgz",
+ "integrity": "sha512-4bYVV3aAMtDTTu4+xsDYa6sy9GyJ69/amsu9sYF2zqjiEoZA5xJi3BrfX3uY+/IekIu7MwdObdbDWpoZdBv3/A==",
+ "license": "MIT"
+ },
+ "node_modules/json-parse-even-better-errors": {
+ "version": "2.3.1",
+ "resolved": "https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-2.3.1.tgz",
+ "integrity": "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==",
+ "license": "MIT"
+ },
+ "node_modules/lines-and-columns": {
+ "version": "1.2.4",
+ "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz",
+ "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==",
+ "license": "MIT"
+ },
+ "node_modules/log-symbols": {
+ "version": "6.0.0",
+ "resolved": "https://registry.npmjs.org/log-symbols/-/log-symbols-6.0.0.tgz",
+ "integrity": "sha512-i24m8rpwhmPIS4zscNzK6MSEhk0DUWa/8iYQWxhffV8jkI4Phvs3F+quL5xvS0gdQR0FyTCMMH33Y78dDTzzIw==",
+ "license": "MIT",
+ "dependencies": {
+ "chalk": "^5.3.0",
+ "is-unicode-supported": "^1.3.0"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/log-symbols/node_modules/is-unicode-supported": {
+ "version": "1.3.0",
+ "resolved": "https://registry.npmjs.org/is-unicode-supported/-/is-unicode-supported-1.3.0.tgz",
+ "integrity": "sha512-43r2mRvz+8JRIKnWJ+3j8JtjRKZ6GmjzfaE/qiBJnikNnYv/6bagRJ1kUhNk8R5EX/GkobD+r+sfxCPJsiKBLQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/lru-cache": {
+ "version": "7.18.3",
+ "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-7.18.3.tgz",
+ "integrity": "sha512-jumlc0BIUrS3qJGgIkWZsyfAM7NCWiBcCDhnd+3NNM5KbBmLTgHVfWBcg6W+rLUsIpzpERPsvwUP7CckAQSOoA==",
+ "license": "ISC",
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/mimic-function": {
+ "version": "5.0.1",
+ "resolved": "https://registry.npmjs.org/mimic-function/-/mimic-function-5.0.1.tgz",
+ "integrity": "sha512-VP79XUPxV2CigYP3jWwAUFSku2aKqBH7uTAapFWCBqutsbmDo96KY5o8uh6U+/YSIn5OxJnXp73beVkpqMIGhA==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/mitt": {
+ "version": "3.0.1",
+ "resolved": "https://registry.npmjs.org/mitt/-/mitt-3.0.1.tgz",
+ "integrity": "sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw==",
+ "license": "MIT"
+ },
+ "node_modules/ms": {
+ "version": "2.1.3",
+ "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
+ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
+ "license": "MIT"
+ },
+ "node_modules/netmask": {
+ "version": "2.0.2",
+ "resolved": "https://registry.npmjs.org/netmask/-/netmask-2.0.2.tgz",
+ "integrity": "sha512-dBpDMdxv9Irdq66304OLfEmQ9tbNRFnFTuZiLo+bD+r332bBmMJ8GBLXklIXXgxd3+v9+KUnZaUR5PJMa75Gsg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4.0"
+ }
+ },
+ "node_modules/once": {
+ "version": "1.4.0",
+ "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz",
+ "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==",
+ "license": "ISC",
+ "dependencies": {
+ "wrappy": "1"
+ }
+ },
+ "node_modules/onetime": {
+ "version": "7.0.0",
+ "resolved": "https://registry.npmjs.org/onetime/-/onetime-7.0.0.tgz",
+ "integrity": "sha512-VXJjc87FScF88uafS3JllDgvAm+c/Slfz06lorj2uAY34rlUu0Nt+v8wreiImcrgAjjIHp1rXpTDlLOGw29WwQ==",
+ "license": "MIT",
+ "dependencies": {
+ "mimic-function": "^5.0.0"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/ora": {
+ "version": "8.2.0",
+ "resolved": "https://registry.npmjs.org/ora/-/ora-8.2.0.tgz",
+ "integrity": "sha512-weP+BZ8MVNnlCm8c0Qdc1WSWq4Qn7I+9CJGm7Qali6g44e/PUzbjNqJX5NJ9ljlNMosfJvg1fKEGILklK9cwnw==",
+ "license": "MIT",
+ "dependencies": {
+ "chalk": "^5.3.0",
+ "cli-cursor": "^5.0.0",
+ "cli-spinners": "^2.9.2",
+ "is-interactive": "^2.0.0",
+ "is-unicode-supported": "^2.0.0",
+ "log-symbols": "^6.0.0",
+ "stdin-discarder": "^0.2.2",
+ "string-width": "^7.2.0",
+ "strip-ansi": "^7.1.0"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/pac-proxy-agent": {
+ "version": "7.2.0",
+ "resolved": "https://registry.npmjs.org/pac-proxy-agent/-/pac-proxy-agent-7.2.0.tgz",
+ "integrity": "sha512-TEB8ESquiLMc0lV8vcd5Ql/JAKAoyzHFXaStwjkzpOpC5Yv+pIzLfHvjTSdf3vpa2bMiUQrg9i6276yn8666aA==",
+ "license": "MIT",
+ "dependencies": {
+ "@tootallnate/quickjs-emscripten": "^0.23.0",
+ "agent-base": "^7.1.2",
+ "debug": "^4.3.4",
+ "get-uri": "^6.0.1",
+ "http-proxy-agent": "^7.0.0",
+ "https-proxy-agent": "^7.0.6",
+ "pac-resolver": "^7.0.1",
+ "socks-proxy-agent": "^8.0.5"
+ },
+ "engines": {
+ "node": ">= 14"
+ }
+ },
+ "node_modules/pac-resolver": {
+ "version": "7.0.1",
+ "resolved": "https://registry.npmjs.org/pac-resolver/-/pac-resolver-7.0.1.tgz",
+ "integrity": "sha512-5NPgf87AT2STgwa2ntRMr45jTKrYBGkVU36yT0ig/n/GMAa3oPqhZfIQ2kMEimReg0+t9kZViDVZ83qfVUlckg==",
+ "license": "MIT",
+ "dependencies": {
+ "degenerator": "^5.0.0",
+ "netmask": "^2.0.2"
+ },
+ "engines": {
+ "node": ">= 14"
+ }
+ },
+ "node_modules/parent-module": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz",
+ "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==",
+ "license": "MIT",
+ "dependencies": {
+ "callsites": "^3.0.0"
+ },
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/parse-json": {
+ "version": "5.2.0",
+ "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-5.2.0.tgz",
+ "integrity": "sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==",
+ "license": "MIT",
+ "dependencies": {
+ "@babel/code-frame": "^7.0.0",
+ "error-ex": "^1.3.1",
+ "json-parse-even-better-errors": "^2.3.0",
+ "lines-and-columns": "^1.1.6"
+ },
+ "engines": {
+ "node": ">=8"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/pend": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/pend/-/pend-1.2.0.tgz",
+ "integrity": "sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg==",
+ "license": "MIT"
+ },
+ "node_modules/picocolors": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
+ "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==",
+ "license": "ISC"
+ },
+ "node_modules/progress": {
+ "version": "2.0.3",
+ "resolved": "https://registry.npmjs.org/progress/-/progress-2.0.3.tgz",
+ "integrity": "sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.4.0"
+ }
+ },
+ "node_modules/proxy-agent": {
+ "version": "6.5.0",
+ "resolved": "https://registry.npmjs.org/proxy-agent/-/proxy-agent-6.5.0.tgz",
+ "integrity": "sha512-TmatMXdr2KlRiA2CyDu8GqR8EjahTG3aY3nXjdzFyoZbmB8hrBsTyMezhULIXKnC0jpfjlmiZ3+EaCzoInSu/A==",
+ "license": "MIT",
+ "dependencies": {
+ "agent-base": "^7.1.2",
+ "debug": "^4.3.4",
+ "http-proxy-agent": "^7.0.1",
+ "https-proxy-agent": "^7.0.6",
+ "lru-cache": "^7.14.1",
+ "pac-proxy-agent": "^7.1.0",
+ "proxy-from-env": "^1.1.0",
+ "socks-proxy-agent": "^8.0.5"
+ },
+ "engines": {
+ "node": ">= 14"
+ }
+ },
+ "node_modules/proxy-from-env": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz",
+ "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==",
+ "license": "MIT"
+ },
+ "node_modules/pump": {
+ "version": "3.0.2",
+ "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.2.tgz",
+ "integrity": "sha512-tUPXtzlGM8FE3P0ZL6DVs/3P58k9nk8/jZeQCurTJylQA8qFYzHFfhBJkuqyE0FifOsQ0uKWekiZ5g8wtr28cw==",
+ "license": "MIT",
+ "dependencies": {
+ "end-of-stream": "^1.1.0",
+ "once": "^1.3.1"
+ }
+ },
+ "node_modules/puppeteer": {
+ "version": "24.5.0",
+ "resolved": "https://registry.npmjs.org/puppeteer/-/puppeteer-24.5.0.tgz",
+ "integrity": "sha512-3m0B48gj1A8cK01ma49WwjE8mg4i9UmnR2lP64rwBiLacJ2V20FpT67MgSUyzfz9BcHMQQweuF6Q854mnIYTqg==",
+ "hasInstallScript": true,
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@puppeteer/browsers": "2.9.0",
+ "chromium-bidi": "3.0.0",
+ "cosmiconfig": "^9.0.0",
+ "devtools-protocol": "0.0.1413902",
+ "puppeteer-core": "24.5.0",
+ "typed-query-selector": "^2.12.0"
+ },
+ "bin": {
+ "puppeteer": "lib/cjs/puppeteer/node/cli.js"
+ },
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/puppeteer-core": {
+ "version": "24.5.0",
+ "resolved": "https://registry.npmjs.org/puppeteer-core/-/puppeteer-core-24.5.0.tgz",
+ "integrity": "sha512-vqibSk7xGOoqOlPUk3H+Iz02b4jCEd5QxaiuXclqyyBrJ6ZK22mXkg9HBSpyZePq6vKWh5ZAqUilSnbF2bv4Jg==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@puppeteer/browsers": "2.9.0",
+ "chromium-bidi": "3.0.0",
+ "debug": "^4.4.0",
+ "devtools-protocol": "0.0.1413902",
+ "typed-query-selector": "^2.12.0",
+ "ws": "^8.18.1"
+ },
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/require-directory": {
+ "version": "2.1.1",
+ "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz",
+ "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/resolve-from": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz",
+ "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=4"
+ }
+ },
+ "node_modules/restore-cursor": {
+ "version": "5.1.0",
+ "resolved": "https://registry.npmjs.org/restore-cursor/-/restore-cursor-5.1.0.tgz",
+ "integrity": "sha512-oMA2dcrw6u0YfxJQXm342bFKX/E4sG9rbTzO9ptUcR/e8A33cHuvStiYOwH7fszkZlZ1z/ta9AAoPk2F4qIOHA==",
+ "license": "MIT",
+ "dependencies": {
+ "onetime": "^7.0.0",
+ "signal-exit": "^4.1.0"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/semver": {
+ "version": "7.7.1",
+ "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.1.tgz",
+ "integrity": "sha512-hlq8tAfn0m/61p4BVRcPzIGr6LKiMwo4VM6dGi6pt4qcRkmNzTcWq6eCEjEh+qXjkMDvPlOFFSGwQjoEa6gyMA==",
+ "license": "ISC",
+ "bin": {
+ "semver": "bin/semver.js"
+ },
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/signal-exit": {
+ "version": "4.1.0",
+ "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz",
+ "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==",
+ "license": "ISC",
+ "engines": {
+ "node": ">=14"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/isaacs"
+ }
+ },
+ "node_modules/smart-buffer": {
+ "version": "4.2.0",
+ "resolved": "https://registry.npmjs.org/smart-buffer/-/smart-buffer-4.2.0.tgz",
+ "integrity": "sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 6.0.0",
+ "npm": ">= 3.0.0"
+ }
+ },
+ "node_modules/socks": {
+ "version": "2.8.4",
+ "resolved": "https://registry.npmjs.org/socks/-/socks-2.8.4.tgz",
+ "integrity": "sha512-D3YaD0aRxR3mEcqnidIs7ReYJFVzWdd6fXJYUM8ixcQcJRGTka/b3saV0KflYhyVJXKhb947GndU35SxYNResQ==",
+ "license": "MIT",
+ "dependencies": {
+ "ip-address": "^9.0.5",
+ "smart-buffer": "^4.2.0"
+ },
+ "engines": {
+ "node": ">= 10.0.0",
+ "npm": ">= 3.0.0"
+ }
+ },
+ "node_modules/socks-proxy-agent": {
+ "version": "8.0.5",
+ "resolved": "https://registry.npmjs.org/socks-proxy-agent/-/socks-proxy-agent-8.0.5.tgz",
+ "integrity": "sha512-HehCEsotFqbPW9sJ8WVYB6UbmIMv7kUUORIF2Nncq4VQvBfNBLibW9YZR5dlYCSUhwcD628pRllm7n+E+YTzJw==",
+ "license": "MIT",
+ "dependencies": {
+ "agent-base": "^7.1.2",
+ "debug": "^4.3.4",
+ "socks": "^2.8.3"
+ },
+ "engines": {
+ "node": ">= 14"
+ }
+ },
+ "node_modules/source-map": {
+ "version": "0.6.1",
+ "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz",
+ "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==",
+ "license": "BSD-3-Clause",
+ "optional": true,
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/sprintf-js": {
+ "version": "1.1.3",
+ "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.1.3.tgz",
+ "integrity": "sha512-Oo+0REFV59/rz3gfJNKQiBlwfHaSESl1pcGyABQsnnIfWOFt6JNj5gCog2U6MLZ//IGYD+nA8nI+mTShREReaA==",
+ "license": "BSD-3-Clause"
+ },
+ "node_modules/stdin-discarder": {
+ "version": "0.2.2",
+ "resolved": "https://registry.npmjs.org/stdin-discarder/-/stdin-discarder-0.2.2.tgz",
+ "integrity": "sha512-UhDfHmA92YAlNnCfhmq0VeNL5bDbiZGg7sZ2IvPsXubGkiNa9EC+tUTsjBRsYUAz87btI6/1wf4XoVvQ3uRnmQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/streamx": {
+ "version": "2.22.0",
+ "resolved": "https://registry.npmjs.org/streamx/-/streamx-2.22.0.tgz",
+ "integrity": "sha512-sLh1evHOzBy/iWRiR6d1zRcLao4gGZr3C1kzNz4fopCOKJb6xD9ub8Mpi9Mr1R6id5o43S+d93fI48UC5uM9aw==",
+ "license": "MIT",
+ "dependencies": {
+ "fast-fifo": "^1.3.2",
+ "text-decoder": "^1.1.0"
+ },
+ "optionalDependencies": {
+ "bare-events": "^2.2.0"
+ }
+ },
+ "node_modules/string-width": {
+ "version": "7.2.0",
+ "resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz",
+ "integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==",
+ "license": "MIT",
+ "dependencies": {
+ "emoji-regex": "^10.3.0",
+ "get-east-asian-width": "^1.0.0",
+ "strip-ansi": "^7.1.0"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/strip-ansi": {
+ "version": "7.1.0",
+ "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.0.tgz",
+ "integrity": "sha512-iq6eVVI64nQQTRYq2KtEg2d2uU7LElhTJwsH4YzIHZshxlgZms/wIc4VoDQTlG/IvVIrBKG06CrZnp0qv7hkcQ==",
+ "license": "MIT",
+ "dependencies": {
+ "ansi-regex": "^6.0.1"
+ },
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://github.com/chalk/strip-ansi?sponsor=1"
+ }
+ },
+ "node_modules/tar-fs": {
+ "version": "3.0.8",
+ "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-3.0.8.tgz",
+ "integrity": "sha512-ZoROL70jptorGAlgAYiLoBLItEKw/fUxg9BSYK/dF/GAGYFJOJJJMvjPAKDJraCXFwadD456FCuvLWgfhMsPwg==",
+ "license": "MIT",
+ "dependencies": {
+ "pump": "^3.0.0",
+ "tar-stream": "^3.1.5"
+ },
+ "optionalDependencies": {
+ "bare-fs": "^4.0.1",
+ "bare-path": "^3.0.0"
+ }
+ },
+ "node_modules/tar-stream": {
+ "version": "3.1.7",
+ "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-3.1.7.tgz",
+ "integrity": "sha512-qJj60CXt7IU1Ffyc3NJMjh6EkuCFej46zUqJ4J7pqYlThyd9bO0XBTmcOIhSzZJVWfsLks0+nle/j538YAW9RQ==",
+ "license": "MIT",
+ "dependencies": {
+ "b4a": "^1.6.4",
+ "fast-fifo": "^1.2.0",
+ "streamx": "^2.15.0"
+ }
+ },
+ "node_modules/text-decoder": {
+ "version": "1.2.3",
+ "resolved": "https://registry.npmjs.org/text-decoder/-/text-decoder-1.2.3.tgz",
+ "integrity": "sha512-3/o9z3X0X0fTupwsYvR03pJ/DjWuqqrfwBgTQzdWDiQSm9KitAyz/9WqsT2JQW7KV2m+bC2ol/zqpW37NHxLaA==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "b4a": "^1.6.4"
+ }
+ },
+ "node_modules/tslib": {
+ "version": "2.8.1",
+ "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
+ "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
+ "license": "0BSD"
+ },
+ "node_modules/typed-query-selector": {
+ "version": "2.12.0",
+ "resolved": "https://registry.npmjs.org/typed-query-selector/-/typed-query-selector-2.12.0.tgz",
+ "integrity": "sha512-SbklCd1F0EiZOyPiW192rrHZzZ5sBijB6xM+cpmrwDqObvdtunOHHIk9fCGsoK5JVIYXoyEp4iEdE3upFH3PAg==",
+ "license": "MIT"
+ },
+ "node_modules/typescript": {
+ "version": "5.8.2",
+ "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.8.2.tgz",
+ "integrity": "sha512-aJn6wq13/afZp/jT9QZmwEjDqqvSGp1VT5GVg+f/t6/oVyrgXM6BY1h9BRh/O5p3PlUPAe+WuiEZOmb/49RqoQ==",
+ "devOptional": true,
+ "license": "Apache-2.0",
+ "bin": {
+ "tsc": "bin/tsc",
+ "tsserver": "bin/tsserver"
+ },
+ "engines": {
+ "node": ">=14.17"
+ }
+ },
+ "node_modules/undici-types": {
+ "version": "6.21.0",
+ "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
+ "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
+ "license": "MIT",
+ "optional": true
+ },
+ "node_modules/wrap-ansi": {
+ "version": "7.0.0",
+ "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz",
+ "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==",
+ "license": "MIT",
+ "dependencies": {
+ "ansi-styles": "^4.0.0",
+ "string-width": "^4.1.0",
+ "strip-ansi": "^6.0.0"
+ },
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/chalk/wrap-ansi?sponsor=1"
+ }
+ },
+ "node_modules/wrap-ansi/node_modules/ansi-regex": {
+ "version": "5.0.1",
+ "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
+ "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/wrap-ansi/node_modules/emoji-regex": {
+ "version": "8.0.0",
+ "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
+ "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
+ "license": "MIT"
+ },
+ "node_modules/wrap-ansi/node_modules/string-width": {
+ "version": "4.2.3",
+ "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
+ "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
+ "license": "MIT",
+ "dependencies": {
+ "emoji-regex": "^8.0.0",
+ "is-fullwidth-code-point": "^3.0.0",
+ "strip-ansi": "^6.0.1"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/wrap-ansi/node_modules/strip-ansi": {
+ "version": "6.0.1",
+ "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
+ "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
+ "license": "MIT",
+ "dependencies": {
+ "ansi-regex": "^5.0.1"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/wrappy": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz",
+ "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==",
+ "license": "ISC"
+ },
+ "node_modules/ws": {
+ "version": "8.18.1",
+ "resolved": "https://registry.npmjs.org/ws/-/ws-8.18.1.tgz",
+ "integrity": "sha512-RKW2aJZMXeMxVpnZ6bck+RswznaxmzdULiBr6KY7XkTnW8uvt0iT9H5DkHUChXrc+uurzwa0rVI16n/Xzjdz1w==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=10.0.0"
+ },
+ "peerDependencies": {
+ "bufferutil": "^4.0.1",
+ "utf-8-validate": ">=5.0.2"
+ },
+ "peerDependenciesMeta": {
+ "bufferutil": {
+ "optional": true
+ },
+ "utf-8-validate": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/y18n": {
+ "version": "5.0.8",
+ "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz",
+ "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==",
+ "license": "ISC",
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/yargs": {
+ "version": "17.7.2",
+ "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz",
+ "integrity": "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==",
+ "license": "MIT",
+ "dependencies": {
+ "cliui": "^8.0.1",
+ "escalade": "^3.1.1",
+ "get-caller-file": "^2.0.5",
+ "require-directory": "^2.1.1",
+ "string-width": "^4.2.3",
+ "y18n": "^5.0.5",
+ "yargs-parser": "^21.1.1"
+ },
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/yargs-parser": {
+ "version": "21.1.1",
+ "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz",
+ "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==",
+ "license": "ISC",
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/yargs/node_modules/ansi-regex": {
+ "version": "5.0.1",
+ "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
+ "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/yargs/node_modules/emoji-regex": {
+ "version": "8.0.0",
+ "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
+ "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
+ "license": "MIT"
+ },
+ "node_modules/yargs/node_modules/string-width": {
+ "version": "4.2.3",
+ "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
+ "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
+ "license": "MIT",
+ "dependencies": {
+ "emoji-regex": "^8.0.0",
+ "is-fullwidth-code-point": "^3.0.0",
+ "strip-ansi": "^6.0.1"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/yargs/node_modules/strip-ansi": {
+ "version": "6.0.1",
+ "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
+ "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
+ "license": "MIT",
+ "dependencies": {
+ "ansi-regex": "^5.0.1"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/yauzl": {
+ "version": "2.10.0",
+ "resolved": "https://registry.npmjs.org/yauzl/-/yauzl-2.10.0.tgz",
+ "integrity": "sha512-p4a9I6X6nu6IhoGmBqAcbJy1mlC4j27vEPZX9F4L4/vZT3Lyq1VkFHw/V/PUcB9Buo+DG3iHkT0x3Qya58zc3g==",
+ "license": "MIT",
+ "dependencies": {
+ "buffer-crc32": "~0.2.3",
+ "fd-slicer": "~1.1.0"
+ }
+ },
+ "node_modules/zod": {
+ "version": "3.24.2",
+ "resolved": "https://registry.npmjs.org/zod/-/zod-3.24.2.tgz",
+ "integrity": "sha512-lY7CDW43ECgW9u1TcT3IoXHflywfVqDYze4waEz812jR/bZ8FHDsl7pFQoSZTz5N+2NqRXs8GBwnAwo3ZNxqhQ==",
+ "license": "MIT",
+ "funding": {
+ "url": "https://github.com/sponsors/colinhacks"
+ }
+ }
+ }
+}
diff --git a/plugins/optimization-detective/priming-cli/package.json b/plugins/optimization-detective/priming-cli/package.json
new file mode 100644
index 0000000000..f7c76d049b
--- /dev/null
+++ b/plugins/optimization-detective/priming-cli/package.json
@@ -0,0 +1,24 @@
+{
+ "name": "@wordpress/performance-od-url-metric-collector",
+ "version": "1.0.0",
+ "description": "CLI tool to prime URL metrics for Optimization Detective",
+ "main": "index.js",
+ "type": "module",
+ "bin": {
+ "od-prime": "./index.js"
+ },
+ "scripts": {
+ "start": "node index.js"
+ },
+ "author": "WordPress Performance Team",
+ "license": "GPL-2.0-or-later",
+ "dependencies": {
+ "chalk": "^5.4.1",
+ "commander": "^13.1.0",
+ "ora": "^8.2.0",
+ "puppeteer": "^24.4.0"
+ },
+ "devDependencies": {
+ "typescript": "^5.8.2"
+ }
+}
\ No newline at end of file
diff --git a/plugins/optimization-detective/priming-cli/types.ts b/plugins/optimization-detective/priming-cli/types.ts
new file mode 100644
index 0000000000..d77db87051
--- /dev/null
+++ b/plugins/optimization-detective/priming-cli/types.ts
@@ -0,0 +1,30 @@
+export interface URLBatchCursor {
+ provider_index: number;
+ subtype_index: number;
+ page_number: number;
+ offset_within_page: number;
+ batch_size: number;
+}
+
+export interface Viewport {
+ width: number;
+ height: number;
+}
+
+export interface URLGroup {
+ url: string;
+ viewports: Viewport[];
+}
+
+export interface URLBatchResponse {
+ urlGroups: URLGroup[];
+ cursor: URLBatchCursor | null;
+ verificationToken: string;
+ isDebug: boolean;
+}
+
+export interface URLPrimingTask {
+ url: string;
+ width: number;
+ height: number;
+}
diff --git a/plugins/optimization-detective/settings.php b/plugins/optimization-detective/settings.php
new file mode 100644
index 0000000000..357b24c42c
--- /dev/null
+++ b/plugins/optimization-detective/settings.php
@@ -0,0 +1,106 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ ',
+ esc_url( admin_url( 'tools.php?page=od-optimization-detective' ) ),
+ esc_html__( 'Settings', 'optimization-detective' )
+ ),
+ ),
+ $links
+ );
+}
+add_filter( 'plugin_action_links_' . OPTIMIZATION_DETECTIVE_MAIN_FILE, 'od_add_settings_action_link' );
diff --git a/plugins/optimization-detective/storage/class-od-priming-mode-wp-cli.php b/plugins/optimization-detective/storage/class-od-priming-mode-wp-cli.php
new file mode 100644
index 0000000000..506ca7da42
--- /dev/null
+++ b/plugins/optimization-detective/storage/class-od-priming-mode-wp-cli.php
@@ -0,0 +1,115 @@
+]
+ * : Index of the provider.
+ * ---
+ * default: 0
+ * ---
+ *
+ * [--subtype-index=