@elementor/editor-audits 4.3.0-beta1 → 4.3.0-beta3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@elementor/editor-audits",
3
- "version": "4.3.0-beta1",
3
+ "version": "4.3.0-beta3",
4
4
  "private": false,
5
5
  "author": "Elementor Team",
6
6
  "homepage": "https://elementor.com/",
@@ -39,19 +39,19 @@
39
39
  "dev": "tsup --config=../../tsup.dev.ts"
40
40
  },
41
41
  "dependencies": {
42
- "@elementor/editor": "4.3.0-beta1",
43
- "@elementor/editor-mcp": "4.3.0-beta1",
44
- "@elementor/editor-app-bar": "4.3.0-beta1",
45
- "@elementor/editor-elements": "4.3.0-beta1",
46
- "@elementor/editor-floating-panels": "4.3.0-beta1",
47
- "@elementor/editor-notifications": "4.3.0-beta1",
48
- "@elementor/editor-v1-adapters": "4.3.0-beta1",
49
- "@elementor/events": "4.3.0-beta1",
50
- "@elementor/http-client": "4.3.0-beta1",
42
+ "@elementor/editor": "4.3.0-beta3",
43
+ "@elementor/editor-mcp": "4.3.0-beta3",
44
+ "@elementor/editor-app-bar": "4.3.0-beta3",
45
+ "@elementor/editor-elements": "4.3.0-beta3",
46
+ "@elementor/editor-floating-panels": "4.3.0-beta3",
47
+ "@elementor/editor-notifications": "4.3.0-beta3",
48
+ "@elementor/editor-v1-adapters": "4.3.0-beta3",
49
+ "@elementor/events": "4.3.0-beta3",
50
+ "@elementor/http-client": "4.3.0-beta3",
51
51
  "@elementor/icons": "~1.77.0",
52
- "@elementor/locations": "4.3.0-beta1",
53
- "@elementor/session": "4.3.0-beta1",
54
- "@elementor/store": "4.3.0-beta1",
52
+ "@elementor/locations": "4.3.0-beta3",
53
+ "@elementor/session": "4.3.0-beta3",
54
+ "@elementor/store": "4.3.0-beta3",
55
55
  "@elementor/ui": "1.37.5"
56
56
  },
57
57
  "peerDependencies": {
@@ -1,19 +1,38 @@
1
1
  import { httpService } from '@elementor/http-client';
2
2
 
3
3
  import { type PageContextResponse } from '../types';
4
+ import { isNonceInvalidError, refreshAuditsNonce } from '../utils/session-expiration';
4
5
  import { getWindowConfig } from '../utils/window-config';
5
6
 
6
7
  export async function fetchPageContext( documentId: number, attachmentIds: number[] ): Promise< PageContextResponse > {
8
+ return requestPageContext( documentId, attachmentIds, true );
9
+ }
10
+
11
+ async function requestPageContext(
12
+ documentId: number,
13
+ attachmentIds: number[],
14
+ allowNonceRetry: boolean
15
+ ): Promise< PageContextResponse > {
7
16
  const { restNamespace, nonce } = getWindowConfig();
8
17
  const url = `${ restNamespace }/audits/page-context`;
9
18
 
10
- const response = await httpService().get< PageContextResponse >( url, {
11
- params: {
12
- document_id: documentId,
13
- attachment_ids: attachmentIds,
14
- },
15
- headers: { 'X-WP-Nonce': nonce },
16
- } );
19
+ try {
20
+ const response = await httpService().get< PageContextResponse >( url, {
21
+ params: {
22
+ document_id: documentId,
23
+ attachment_ids: attachmentIds,
24
+ },
25
+ headers: { 'X-WP-Nonce': nonce },
26
+ } );
27
+
28
+ return response.data;
29
+ } catch ( error ) {
30
+ if ( ! allowNonceRetry || ! isNonceInvalidError( error ) ) {
31
+ throw error;
32
+ }
33
+
34
+ await refreshAuditsNonce();
17
35
 
18
- return response.data;
36
+ return requestPageContext( documentId, attachmentIds, false );
37
+ }
19
38
  }
@@ -0,0 +1,68 @@
1
+ import { __ } from '@wordpress/i18n';
2
+
3
+ import { type Audit } from '../types';
4
+ import { fetchRenderedHtml } from '../utils/fetch-rendered-html';
5
+ import { hasConsentDefaultCall, hasGoogleTracking } from '../utils/scan-consent-signals';
6
+
7
+ export const audit: Audit = {
8
+ id: 'audits/google-consent-mode',
9
+ title: __( 'Google Consent Mode', 'elementor' ),
10
+ description: __(
11
+ 'Google Analytics, Ads, or Tag Manager are running on this site and may not receive a consent signal, which risks losing conversion/analytics data and compliance exposure.',
12
+ 'elementor'
13
+ ),
14
+ fixHint: __(
15
+ 'Turn on Google Consent Mode in your cookie consent plugin so Google tags respect visitor consent choices.',
16
+ 'elementor'
17
+ ),
18
+ categories: [ 'compliance' ],
19
+ severity: 'warning',
20
+ weight: 1,
21
+ evaluate: async ( ctx ) => {
22
+ if ( ! ctx.pageContext.frontend_url ) {
23
+ return { status: 'skipped', reason: __( 'Page is not published.', 'elementor' ) };
24
+ }
25
+
26
+ const html = await fetchRenderedHtml( ctx.pageContext.frontend_url );
27
+
28
+ if ( ! html ) {
29
+ return { status: 'skipped', reason: __( 'Could not fetch the published page.', 'elementor' ) };
30
+ }
31
+
32
+ if ( ! hasGoogleTracking( html ) ) {
33
+ return { status: 'skipped', reason: __( 'No Google tracking product detected.', 'elementor' ) };
34
+ }
35
+
36
+ if ( hasConsentDefaultCall( html ) ) {
37
+ return { status: 'pass' };
38
+ }
39
+
40
+ const label = __( 'No Google Consent Mode signal was found on the page.', 'elementor' );
41
+ const isCookiezReady = ctx.pageContext.cookiez_plugin_installed && ctx.pageContext.cookiez_plugin_active;
42
+
43
+ if ( isCookiezReady ) {
44
+ return {
45
+ status: 'fail',
46
+ violations: [
47
+ {
48
+ auditId: audit.id,
49
+ label,
50
+ ctaLabel: __( 'Enable', 'elementor' ),
51
+ externalUrl: ctx.pageContext.cookiez_consent_mode_settings_url,
52
+ },
53
+ ],
54
+ };
55
+ }
56
+
57
+ return {
58
+ status: 'fail',
59
+ violations: [
60
+ {
61
+ auditId: audit.id,
62
+ label,
63
+ externalUrl: ctx.pageContext.cookiez_plugin_action_url,
64
+ },
65
+ ],
66
+ };
67
+ },
68
+ };
@@ -28,6 +28,7 @@ export const audit: Audit = {
28
28
  auditId: audit.id,
29
29
  label: __( 'No privacy policy page is set.', 'elementor' ),
30
30
  externalUrl: ctx.pageContext.privacy_settings_url,
31
+ ctaLabel: __( 'Create', 'elementor' ),
31
32
  },
32
33
  ],
33
34
  };
@@ -0,0 +1,30 @@
1
+ import { __ } from '@wordpress/i18n';
2
+
3
+ import { type Audit } from '../types';
4
+
5
+ export const audit: Audit = {
6
+ id: 'audits/scan-for-cookies',
7
+ title: __( 'Scan for cookies', 'elementor' ),
8
+ description: __(
9
+ "Your site may be setting cookies you haven't disclosed. Some countries require a banner even without a full consent policy. Scan to see what needs disclosure.",
10
+ 'elementor'
11
+ ),
12
+ fixHint: __( 'Run a Cookiez scan on this page to see which cookies need disclosure.', 'elementor' ),
13
+ categories: [ 'compliance' ],
14
+ severity: 'info',
15
+ weight: 0,
16
+ evaluate: ( ctx ) => {
17
+ const isReady = ctx.pageContext.cookiez_plugin_installed && ctx.pageContext.cookiez_plugin_active;
18
+
19
+ return {
20
+ status: 'fail',
21
+ violations: [
22
+ {
23
+ auditId: audit.id,
24
+ label: __( 'This page has not been scanned for cookies yet.', 'elementor' ),
25
+ externalUrl: isReady ? ctx.pageContext.cookiez_scan_url : ctx.pageContext.cookiez_plugin_action_url,
26
+ },
27
+ ],
28
+ };
29
+ },
30
+ };
@@ -15,7 +15,7 @@ type Props = {
15
15
  export default function StatusSection( { label, count, color = 'default', defaultExpanded = false, children }: Props ) {
16
16
  const [ expanded, setExpanded ] = useState( defaultExpanded );
17
17
 
18
- if ( count === 0 ) {
18
+ if ( React.Children.count( children ) === 0 ) {
19
19
  return null;
20
20
  }
21
21
 
@@ -0,0 +1,22 @@
1
+ import * as React from 'react';
2
+ import { Button } from '@elementor/ui';
3
+
4
+ type Props = {
5
+ ctaLabel: string;
6
+ externalUrl: string;
7
+ };
8
+
9
+ export default function ViolationCtaButton( { ctaLabel, externalUrl }: Props ) {
10
+ const handleClick = ( event: React.MouseEvent< HTMLButtonElement > ) => {
11
+ event.stopPropagation();
12
+ event.preventDefault();
13
+
14
+ window.open( externalUrl, '_blank', 'noopener' );
15
+ };
16
+
17
+ return (
18
+ <Button variant="outlined" color="secondary" size="small" onClick={ handleClick }>
19
+ { ctaLabel }
20
+ </Button>
21
+ );
22
+ }
@@ -8,9 +8,11 @@ import { __ } from '@wordpress/i18n';
8
8
  import { focusViolation } from '../hooks/focus-violation';
9
9
  import { type AuditMeta, type AuditViolation } from '../types';
10
10
  import { buildAngiePrompt } from '../utils/build-angie-prompt';
11
+ import { isScoredAudit } from '../utils/is-scored-audit';
11
12
  import { onKeyboardClick } from '../utils/keyboard-click';
12
13
  import FixViolationWithAngie from './fix-violation-with-angie';
13
14
  import SeverityIcon from './severity-icons';
15
+ import ViolationCtaButton from './violation-cta-button';
14
16
  import ViolationIcon from './violation-icons';
15
17
 
16
18
  type Props = {
@@ -33,9 +35,11 @@ function StatusIndicator( { audit, violations }: Pick< Props, 'audit' | 'violati
33
35
  if ( violations ) {
34
36
  return (
35
37
  <>
36
- <Typography variant="caption" color="text.secondary" fontWeight="bold">
37
- { violations.length }
38
- </Typography>
38
+ { isScoredAudit( audit ) && (
39
+ <Typography variant="caption" color="text.secondary" fontWeight="bold">
40
+ { violations.length }
41
+ </Typography>
42
+ ) }
39
43
  <SeverityIcon severity={ audit.severity } />
40
44
  </>
41
45
  );
@@ -157,7 +161,18 @@ export default function ViolationRow( { audit, skipReason, violations }: Props )
157
161
  { violation.angieFix && (
158
162
  <FixViolationWithAngie prompt={ buildAngiePrompt( rowLabel ) } />
159
163
  ) }
160
- <EyeIcon className="violation-hover-icon" fontSize="tiny" aria-hidden={ true } />
164
+ { violation.ctaLabel && violation.externalUrl ? (
165
+ <ViolationCtaButton
166
+ ctaLabel={ violation.ctaLabel }
167
+ externalUrl={ violation.externalUrl }
168
+ />
169
+ ) : (
170
+ <EyeIcon
171
+ className="violation-hover-icon"
172
+ fontSize="tiny"
173
+ aria-hidden={ true }
174
+ />
175
+ ) }
161
176
  </Box>
162
177
  );
163
178
  } ) }
@@ -5,6 +5,7 @@ import { __useDispatch as useDispatch, __useSelector as useSelector } from '@ele
5
5
  import { runPageAudit } from '../runner';
6
6
  import { type GlobalState, selectError, selectReport, selectStatus, slice } from '../store';
7
7
  import { getPersistedReport, persistReport } from '../utils/report-storage';
8
+ import { SessionExpiredError } from '../utils/session-expiration';
8
9
 
9
10
  export function useAuditReport() {
10
11
  const status = useSelector( ( state: GlobalState ) => selectStatus( state ) );
@@ -35,6 +36,11 @@ export function useAuditReport() {
35
36
  dispatch( slice.actions.runSucceeded( nextReport ) );
36
37
  persistReport( documentIdToRun, nextReport );
37
38
  } catch ( e ) {
39
+ if ( e instanceof SessionExpiredError ) {
40
+ dispatch( slice.actions.runAborted() );
41
+ return;
42
+ }
43
+
38
44
  dispatch( slice.actions.runFailed( e instanceof Error ? e.message : 'Unknown error' ) );
39
45
  }
40
46
  };
@@ -3,6 +3,7 @@ import * as cookiePolicy from './audits/cookie-policy';
3
3
  import * as deepNesting from './audits/deep-nesting';
4
4
  import * as defaultDesignSystem from './audits/default-design-system';
5
5
  import * as deprecatedWidgets from './audits/deprecated-widgets';
6
+ import * as googleConsentMode from './audits/google-consent-mode';
6
7
  import * as headingStructure from './audits/heading-structure';
7
8
  import * as hiddenElements from './audits/hidden-elements';
8
9
  import * as imagesAltText from './audits/images-alt-text';
@@ -15,6 +16,7 @@ import * as preferGlobalColors from './audits/prefer-global-colors';
15
16
  import * as preferGlobalFonts from './audits/prefer-global-fonts';
16
17
  import * as privacyPolicy from './audits/privacy-policy';
17
18
  import * as robotsNoindex from './audits/robots-noindex';
19
+ import * as scanForCookies from './audits/scan-for-cookies';
18
20
  import * as sectionsAndColumns from './audits/sections-and-columns';
19
21
  import * as siteIdentity from './audits/site-identity';
20
22
  import * as tooManyWidgets from './audits/too-many-widgets';
@@ -47,6 +49,8 @@ const AUDITS: Audit[] = [
47
49
  privacyPolicy.audit,
48
50
  accessibilityPolicy.audit,
49
51
  cookiePolicy.audit,
52
+ scanForCookies.audit,
53
+ googleConsentMode.audit,
50
54
  ];
51
55
 
52
56
  export function registerAllAudits(): void {
@@ -49,6 +49,14 @@ export const PROMOTIONS: PromotionConfig[] = [
49
49
  run.result.status === 'fail' ? __( 'Generate cookie policy', 'elementor' ) : null,
50
50
  getCtaUrl: firstFailExternalUrl,
51
51
  },
52
+ {
53
+ auditId: 'audits/scan-for-cookies',
54
+ icon: ElementorCookieIcon,
55
+ ctaLabel: __( 'Scan', 'elementor' ),
56
+ formatSubtitle: ( run ) =>
57
+ run.result.status === 'fail' ? __( 'Scan this page for cookies', 'elementor' ) : null,
58
+ getCtaUrl: firstFailExternalUrl,
59
+ },
52
60
  {
53
61
  auditId: 'audits/images-too-large',
54
62
  icon: ShieldHalfFilledIcon,
@@ -26,6 +26,9 @@ export const slice = __createSlice( {
26
26
  state.status = 'error';
27
27
  state.error = action.payload;
28
28
  },
29
+ runAborted( state ) {
30
+ state.status = state.report ? 'ready' : 'idle';
31
+ },
29
32
  reportRestored( state, action: PayloadAction< PageAuditReport > ) {
30
33
  state.status = 'ready';
31
34
  state.report = action.payload;
package/src/types.ts CHANGED
@@ -38,6 +38,7 @@ export type AuditViolation = {
38
38
  label: string;
39
39
  detail?: string;
40
40
  angieFix?: boolean;
41
+ ctaLabel?: string;
41
42
  };
42
43
 
43
44
  export type PageContextResponse = {
@@ -65,8 +66,13 @@ export type PageContextResponse = {
65
66
  ally_plugin_url: string;
66
67
  cookiez_plugin_active: boolean;
67
68
  cookiez_plugin_url: string;
69
+ cookiez_plugin_installed: boolean;
70
+ cookiez_plugin_action_url: string;
71
+ cookiez_scan_url: string;
72
+ cookiez_consent_mode_settings_url: string;
68
73
  image_optimization_plugin_active: boolean;
69
74
  image_optimization_plugin_url: string;
75
+ frontend_url: string | null;
70
76
  site_identity: {
71
77
  site_name_set: boolean;
72
78
  site_description_set: boolean;
@@ -2,6 +2,7 @@ import { type ChipProps } from '@elementor/ui';
2
2
  import { __ } from '@wordpress/i18n';
3
3
 
4
4
  import { type AuditCategory, type AuditResult, type AuditRun, type PageAuditReport } from '../types';
5
+ import { isScoredAudit } from './is-scored-audit';
5
6
  import { sortFailedAuditResults } from './sort-failed-audits';
6
7
 
7
8
  export type AuditStatusGroup = 'fail' | 'pass' | 'skipped';
@@ -37,7 +38,11 @@ export function partitionAuditResults(
37
38
  switch ( run.result.status ) {
38
39
  case 'fail':
39
40
  failed.push( { ...run, result: run.result } );
40
- totalViolations += run.result.violations.length;
41
+
42
+ if ( isScoredAudit( run.audit ) ) {
43
+ totalViolations += run.result.violations.length;
44
+ }
45
+
41
46
  break;
42
47
  case 'pass':
43
48
  passed.push( { ...run, result: run.result } );
@@ -61,7 +66,11 @@ export function auditStatusDisplayCounts( report: PageAuditReport ): Record< Aud
61
66
  let skipped = 0;
62
67
  let totalViolations = 0;
63
68
 
64
- for ( const { result } of report.auditResults ) {
69
+ for ( const { audit, result } of report.auditResults ) {
70
+ if ( ! isScoredAudit( audit ) ) {
71
+ continue;
72
+ }
73
+
65
74
  switch ( result.status ) {
66
75
  case 'fail':
67
76
  totalViolations += result.violations.length;
@@ -0,0 +1,24 @@
1
+ const FETCH_TIMEOUT_MS = 10_000;
2
+
3
+ export async function fetchRenderedHtml( url: string | null ): Promise< string | null > {
4
+ if ( ! url ) {
5
+ return null;
6
+ }
7
+
8
+ const timeoutController = new AbortController();
9
+ const timeoutId = setTimeout( () => timeoutController.abort(), FETCH_TIMEOUT_MS );
10
+
11
+ try {
12
+ const response = await fetch( url, { credentials: 'omit', signal: timeoutController.signal } );
13
+
14
+ if ( ! response.ok ) {
15
+ return null;
16
+ }
17
+
18
+ return await response.text();
19
+ } catch {
20
+ return null;
21
+ } finally {
22
+ clearTimeout( timeoutId );
23
+ }
24
+ }
@@ -0,0 +1,5 @@
1
+ import { type AuditMeta } from '../types';
2
+
3
+ export function isScoredAudit( audit: AuditMeta ): boolean {
4
+ return audit.weight > 0;
5
+ }
@@ -0,0 +1,19 @@
1
+ const GOOGLE_TRACKING_PATTERNS = [
2
+ /googletagmanager\.com\/gtag\/js\?id=(G|AW)-/,
3
+ /googletagmanager\.com\/gtm\.js/,
4
+ /\bGTM-[A-Z0-9]+\b/,
5
+ /gtag\(\s*['"]config['"]\s*,\s*['"](G|AW)-/,
6
+ ];
7
+
8
+ const CONSENT_DEFAULT_PATTERNS = [
9
+ /gtag\(\s*['"]consent['"]\s*,\s*['"]default['"]/,
10
+ /dataLayer\.push\(\s*\[\s*['"]consent['"]\s*,\s*['"]default['"]/,
11
+ ];
12
+
13
+ export function hasGoogleTracking( html: string ): boolean {
14
+ return GOOGLE_TRACKING_PATTERNS.some( ( pattern ) => pattern.test( html ) );
15
+ }
16
+
17
+ export function hasConsentDefaultCall( html: string ): boolean {
18
+ return CONSENT_DEFAULT_PATTERNS.some( ( pattern ) => pattern.test( html ) );
19
+ }
@@ -0,0 +1,63 @@
1
+ import { getWindowConfig } from './window-config';
2
+
3
+ const NONCE_INVALID_CODE = 'rest_cookie_invalid_nonce';
4
+ const DEFAULT_AJAX_URL = '/wp-admin/admin-ajax.php';
5
+
6
+ type AjaxErrorLikeResponse = {
7
+ status?: number;
8
+ data?: { code?: string };
9
+ };
10
+
11
+ type AjaxErrorLike = {
12
+ response?: AjaxErrorLikeResponse;
13
+ };
14
+
15
+ type SessionWindow = Window & {
16
+ elementorCommon?: { ajax?: { config?: { url?: string } } };
17
+ };
18
+
19
+ export class SessionExpiredError extends Error {}
20
+
21
+ let nonceRefreshPromise: Promise< string > | null = null;
22
+
23
+ export function isNonceInvalidError( error: unknown ): boolean {
24
+ const response = ( error as AjaxErrorLike | undefined )?.response;
25
+
26
+ return response?.status === 403 && response?.data?.code === NONCE_INVALID_CODE;
27
+ }
28
+
29
+ export async function refreshAuditsNonce(): Promise< string > {
30
+ if ( nonceRefreshPromise ) {
31
+ return nonceRefreshPromise;
32
+ }
33
+
34
+ nonceRefreshPromise = requestFreshNonce();
35
+
36
+ try {
37
+ return await nonceRefreshPromise;
38
+ } finally {
39
+ nonceRefreshPromise = null;
40
+ }
41
+ }
42
+
43
+ async function requestFreshNonce(): Promise< string > {
44
+ const ajaxUrl = ( window as SessionWindow ).elementorCommon?.ajax?.config?.url ?? DEFAULT_AJAX_URL;
45
+ const url = new URL( ajaxUrl, window.location.origin );
46
+ url.searchParams.set( 'action', 'rest-nonce' );
47
+
48
+ const response = await fetch( url.toString(), { credentials: 'same-origin' } );
49
+
50
+ if ( ! response.ok ) {
51
+ throw new Error( `Failed to refresh audits nonce: HTTP ${ response.status }` );
52
+ }
53
+
54
+ const nonce = await response.text();
55
+
56
+ if ( ! nonce || '0' === nonce ) {
57
+ throw new SessionExpiredError( 'Session expired — received invalid nonce' );
58
+ }
59
+
60
+ window.elementorAudits = { ...getWindowConfig(), nonce };
61
+
62
+ return nonce;
63
+ }
@@ -1,6 +1,7 @@
1
1
  import { __, sprintf } from '@wordpress/i18n';
2
2
 
3
3
  import { type AuditCategory, type AuditSeverity, type PageAuditReport } from '../types';
4
+ import { isScoredAudit } from './is-scored-audit';
4
5
 
5
6
  export type SeverityCounts = Record< AuditSeverity, number >;
6
7
 
@@ -18,6 +19,10 @@ export function countSeverities( report: PageAuditReport, category?: AuditCatego
18
19
  continue;
19
20
  }
20
21
 
22
+ if ( ! isScoredAudit( audit ) ) {
23
+ continue;
24
+ }
25
+
21
26
  counts[ audit.severity ] += result.violations.length;
22
27
  }
23
28