@elementor/editor-audits 4.3.0-1036
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/CHANGELOG.md +5 -0
- package/dist/index.d.mts +122 -0
- package/dist/index.d.ts +122 -0
- package/dist/index.js +2822 -0
- package/dist/index.js.map +1 -0
- package/dist/index.mjs +2801 -0
- package/dist/index.mjs.map +1 -0
- package/package.json +65 -0
- package/src/api/page-context-client.ts +19 -0
- package/src/audits/accessibility-policy.ts +32 -0
- package/src/audits/cookie-policy.ts +32 -0
- package/src/audits/deep-nesting.ts +45 -0
- package/src/audits/default-design-system.ts +32 -0
- package/src/audits/deprecated-widgets.ts +57 -0
- package/src/audits/heading-structure.ts +294 -0
- package/src/audits/hidden-elements.ts +41 -0
- package/src/audits/images-alt-text.ts +47 -0
- package/src/audits/images-too-large.ts +61 -0
- package/src/audits/nested-boxed-containers.ts +44 -0
- package/src/audits/page-excerpt.ts +30 -0
- package/src/audits/page-featured-image.ts +29 -0
- package/src/audits/page-title.ts +48 -0
- package/src/audits/prefer-global-colors.ts +52 -0
- package/src/audits/prefer-global-fonts.ts +50 -0
- package/src/audits/privacy-policy.ts +35 -0
- package/src/audits/robots-noindex.ts +35 -0
- package/src/audits/sections-and-columns.ts +39 -0
- package/src/audits/site-identity.ts +62 -0
- package/src/audits/too-many-widgets.ts +43 -0
- package/src/components/audit-feedback.tsx +178 -0
- package/src/components/audit-panel.tsx +55 -0
- package/src/components/category-icons.ts +17 -0
- package/src/components/count-summary-circle.tsx +66 -0
- package/src/components/fix-violation-with-angie.tsx +50 -0
- package/src/components/issues-category-row.tsx +62 -0
- package/src/components/pages/all-audits-page.tsx +65 -0
- package/src/components/pages/category-page.tsx +50 -0
- package/src/components/pages/error-page.tsx +21 -0
- package/src/components/pages/issues-page.tsx +48 -0
- package/src/components/pages/loading-page.tsx +12 -0
- package/src/components/pages/overview-page.tsx +140 -0
- package/src/components/pages/welcome-page.tsx +48 -0
- package/src/components/promotion-card.tsx +43 -0
- package/src/components/promotions.tsx +69 -0
- package/src/components/report-shell.tsx +110 -0
- package/src/components/score-bar.tsx +56 -0
- package/src/components/score-circle.tsx +60 -0
- package/src/components/severity-icons.tsx +26 -0
- package/src/components/status-section.tsx +47 -0
- package/src/components/subpage-header.tsx +29 -0
- package/src/components/violation-icons.tsx +33 -0
- package/src/components/violation-row.tsx +169 -0
- package/src/constants.ts +35 -0
- package/src/editor-app-bar.tsx +13 -0
- package/src/editor-panel.tsx +19 -0
- package/src/hooks/focus-violation.ts +53 -0
- package/src/hooks/use-audit-report.ts +43 -0
- package/src/hooks/use-audit-toggle-props.ts +17 -0
- package/src/index.ts +14 -0
- package/src/init.ts +14 -0
- package/src/register-audits.ts +56 -0
- package/src/register-promotions.ts +75 -0
- package/src/registry.ts +19 -0
- package/src/runner.ts +44 -0
- package/src/store/index.ts +2 -0
- package/src/store/selectors.ts +15 -0
- package/src/store/slice.ts +42 -0
- package/src/types.ts +112 -0
- package/src/utils/audit-status-summary.ts +112 -0
- package/src/utils/build-angie-prompt.ts +9 -0
- package/src/utils/collect-hardcoded-colors.ts +74 -0
- package/src/utils/collect-hardcoded-fonts.ts +70 -0
- package/src/utils/compute-report.ts +51 -0
- package/src/utils/find-audit-run.ts +5 -0
- package/src/utils/image-alt.ts +19 -0
- package/src/utils/image-like-sources.ts +70 -0
- package/src/utils/keyboard-click.ts +10 -0
- package/src/utils/match-global-by-value.ts +5 -0
- package/src/utils/page-attachments.ts +14 -0
- package/src/utils/read-kit-snapshot.ts +149 -0
- package/src/utils/report-storage.ts +16 -0
- package/src/utils/score-thresholds.ts +23 -0
- package/src/utils/severity-counts.ts +59 -0
- package/src/utils/sort-failed-audits.ts +19 -0
- package/src/utils/v1-snapshot.ts +27 -0
- package/src/utils/walk.ts +17 -0
- package/src/utils/window-config.ts +20 -0
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { __ } from '@wordpress/i18n';
|
|
2
|
+
|
|
3
|
+
import { type Audit } from '../types';
|
|
4
|
+
|
|
5
|
+
export const audit: Audit = {
|
|
6
|
+
id: 'audits/cookie-policy',
|
|
7
|
+
title: __( 'Cookie policy', 'elementor' ),
|
|
8
|
+
description: __(
|
|
9
|
+
'A cookie policy is required by privacy regulations such as GDPR (EU) or CCPA (US) to inform visitors about tracking and data collection.',
|
|
10
|
+
'elementor'
|
|
11
|
+
),
|
|
12
|
+
fixHint: __( 'Install the Cookiez plugin to add a cookie policy to your website.', 'elementor' ),
|
|
13
|
+
categories: [ 'compliance' ],
|
|
14
|
+
severity: 'info',
|
|
15
|
+
weight: 1,
|
|
16
|
+
evaluate: ( ctx ) => {
|
|
17
|
+
if ( ctx.pageContext.cookiez_plugin_active ) {
|
|
18
|
+
return { status: 'pass' };
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
return {
|
|
22
|
+
status: 'fail',
|
|
23
|
+
violations: [
|
|
24
|
+
{
|
|
25
|
+
auditId: audit.id,
|
|
26
|
+
label: __( 'Cookiez plugin is not installed or active.', 'elementor' ),
|
|
27
|
+
externalUrl: ctx.pageContext.cookiez_plugin_url,
|
|
28
|
+
},
|
|
29
|
+
],
|
|
30
|
+
};
|
|
31
|
+
},
|
|
32
|
+
};
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { __ } from '@wordpress/i18n';
|
|
2
|
+
|
|
3
|
+
import { type Audit, type AuditViolation } from '../types';
|
|
4
|
+
import { walkElements } from '../utils/walk';
|
|
5
|
+
|
|
6
|
+
const MAX_NESTING_DEPTH = 6;
|
|
7
|
+
|
|
8
|
+
export const audit: Audit = {
|
|
9
|
+
id: 'audits/deep-nesting',
|
|
10
|
+
title: __( 'Deep container nesting', 'elementor' ),
|
|
11
|
+
description: __(
|
|
12
|
+
'Container nesting exceeds the recommended depth. Deep DOM trees hurt rendering performance and readability.',
|
|
13
|
+
'elementor'
|
|
14
|
+
),
|
|
15
|
+
fixHint: __( 'Flatten the layout by removing unnecessary wrapper containers.', 'elementor' ),
|
|
16
|
+
categories: [ 'performance', 'best-practices' ],
|
|
17
|
+
severity: 'warning',
|
|
18
|
+
weight: 5,
|
|
19
|
+
evaluate: ( ctx ) => {
|
|
20
|
+
if ( ctx.elements.tree.length === 0 ) {
|
|
21
|
+
return { status: 'skipped', reason: __( 'No elements', 'elementor' ) };
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const violations: AuditViolation[] = [];
|
|
25
|
+
|
|
26
|
+
walkElements( ctx.elements.tree, ( node, parents ) => {
|
|
27
|
+
if ( node.elType !== 'container' ) {
|
|
28
|
+
return;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const depth = parents.length + 1;
|
|
32
|
+
|
|
33
|
+
if ( depth > MAX_NESTING_DEPTH ) {
|
|
34
|
+
violations.push( {
|
|
35
|
+
auditId: audit.id,
|
|
36
|
+
elementId: node.id,
|
|
37
|
+
targetHint: 'element-settings',
|
|
38
|
+
label: __( 'Container nesting is too deep.', 'elementor' ),
|
|
39
|
+
} );
|
|
40
|
+
}
|
|
41
|
+
} );
|
|
42
|
+
|
|
43
|
+
return violations.length === 0 ? { status: 'pass' } : { status: 'fail', violations };
|
|
44
|
+
},
|
|
45
|
+
};
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { __ } from '@wordpress/i18n';
|
|
2
|
+
|
|
3
|
+
import { type Audit } from '../types';
|
|
4
|
+
|
|
5
|
+
export const audit: Audit = {
|
|
6
|
+
id: 'audits/default-design-system',
|
|
7
|
+
title: __( 'Default website kit', 'elementor' ),
|
|
8
|
+
description: __(
|
|
9
|
+
'Your website is using the default design system colors and fonts. Custom branding makes the website feel uniquely yours.',
|
|
10
|
+
'elementor'
|
|
11
|
+
),
|
|
12
|
+
fixHint: __( 'Open Site Settings and customize your kit (colors, fonts, layout).', 'elementor' ),
|
|
13
|
+
categories: [ 'best-practices' ],
|
|
14
|
+
severity: 'info',
|
|
15
|
+
weight: 1,
|
|
16
|
+
evaluate: ( ctx ) => {
|
|
17
|
+
if ( ! ctx.pageContext.kit_is_default_unchanged ) {
|
|
18
|
+
return { status: 'pass' };
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
return {
|
|
22
|
+
status: 'fail',
|
|
23
|
+
violations: [
|
|
24
|
+
{
|
|
25
|
+
auditId: audit.id,
|
|
26
|
+
label: __( 'Kit appears unchanged from default.', 'elementor' ),
|
|
27
|
+
targetHint: 'site-settings',
|
|
28
|
+
},
|
|
29
|
+
],
|
|
30
|
+
};
|
|
31
|
+
},
|
|
32
|
+
};
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { getWidgetsCache } from '@elementor/editor-elements';
|
|
2
|
+
import { __ } from '@wordpress/i18n';
|
|
3
|
+
|
|
4
|
+
import { type Audit, type AuditViolation } from '../types';
|
|
5
|
+
import { walkElements } from '../utils/walk';
|
|
6
|
+
|
|
7
|
+
const DEPRECATION_CONTROL_NAME = 'deprecation_message';
|
|
8
|
+
|
|
9
|
+
export const audit: Audit = {
|
|
10
|
+
id: 'audits/deprecated-widgets',
|
|
11
|
+
title: __( 'Deprecated widgets', 'elementor' ),
|
|
12
|
+
description: __(
|
|
13
|
+
'Deprecated widgets should not be used. For better capabilities use the recommended replacement.',
|
|
14
|
+
'elementor'
|
|
15
|
+
),
|
|
16
|
+
fixHint: __( 'Replace each deprecated widget with the recommended new widget shown in its panel.', 'elementor' ),
|
|
17
|
+
categories: [ 'best-practices', 'performance' ],
|
|
18
|
+
severity: 'warning',
|
|
19
|
+
weight: 7,
|
|
20
|
+
evaluate: ( ctx ) => {
|
|
21
|
+
const widgetsCache = getWidgetsCache();
|
|
22
|
+
|
|
23
|
+
if ( ! widgetsCache ) {
|
|
24
|
+
return { status: 'skipped', reason: 'widgets-cache-unavailable' };
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const violations: AuditViolation[] = [];
|
|
28
|
+
|
|
29
|
+
walkElements( ctx.elements.tree, ( node ) => {
|
|
30
|
+
if ( node.elType !== 'widget' ) {
|
|
31
|
+
return;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const config = widgetsCache[ node.widgetType ?? '' ];
|
|
35
|
+
|
|
36
|
+
if ( ! config?.controls ) {
|
|
37
|
+
return;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const controls = config.controls as Record< string, unknown >;
|
|
41
|
+
const deprecationControl = controls[ DEPRECATION_CONTROL_NAME ];
|
|
42
|
+
|
|
43
|
+
if ( ! deprecationControl || typeof deprecationControl !== 'object' ) {
|
|
44
|
+
return;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
violations.push( {
|
|
48
|
+
auditId: audit.id,
|
|
49
|
+
elementId: node.id,
|
|
50
|
+
targetHint: 'element-settings',
|
|
51
|
+
label: __( 'Using deprecated widget.', 'elementor' ),
|
|
52
|
+
} );
|
|
53
|
+
} );
|
|
54
|
+
|
|
55
|
+
return violations.length === 0 ? { status: 'pass' } : { status: 'fail', violations };
|
|
56
|
+
},
|
|
57
|
+
};
|
|
@@ -0,0 +1,294 @@
|
|
|
1
|
+
import { __ } from '@wordpress/i18n';
|
|
2
|
+
|
|
3
|
+
import { type Audit, type AuditViolation } from '../types';
|
|
4
|
+
import { walkElements } from '../utils/walk';
|
|
5
|
+
|
|
6
|
+
type HeadingControlDescriptor = {
|
|
7
|
+
control: string;
|
|
8
|
+
when?: Record< string, string | string[] >;
|
|
9
|
+
requiresNonEmpty?: string;
|
|
10
|
+
requiresAnyNonEmpty?: string[];
|
|
11
|
+
repeat?: {
|
|
12
|
+
items: string;
|
|
13
|
+
textField?: string;
|
|
14
|
+
itemWhen?: Record< string, string | string[] >;
|
|
15
|
+
tagSource?: 'widget' | 'item';
|
|
16
|
+
};
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
type HeadingControlsRegistry = Record< string, HeadingControlDescriptor[] >;
|
|
20
|
+
|
|
21
|
+
type PageHeading = {
|
|
22
|
+
elementId: string;
|
|
23
|
+
level: number;
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
type Settings = Record< string, unknown >;
|
|
27
|
+
|
|
28
|
+
const HEADING_LEVELS = [ 'h1', 'h2', 'h3', 'h4', 'h5', 'h6' ] as const;
|
|
29
|
+
|
|
30
|
+
const HEADING_CONTROLS_REGISTRY: HeadingControlsRegistry = {
|
|
31
|
+
// Core
|
|
32
|
+
heading: [ { control: 'header_size', requiresNonEmpty: 'title' } ],
|
|
33
|
+
'e-heading': [ { control: 'tag', requiresNonEmpty: 'title' } ],
|
|
34
|
+
'icon-box': [ { control: 'title_size', requiresNonEmpty: 'title_text' } ],
|
|
35
|
+
'image-box': [ { control: 'title_size', requiresNonEmpty: 'title_text' } ],
|
|
36
|
+
counter: [ { control: 'title_tag', requiresNonEmpty: 'title' } ],
|
|
37
|
+
progress: [ { control: 'title_tag', when: { title_display: 'yes' }, requiresNonEmpty: 'title' } ],
|
|
38
|
+
accordion: [ { control: 'title_html_tag', repeat: { items: 'tabs', textField: 'tab_title' } } ],
|
|
39
|
+
toggle: [ { control: 'title_html_tag', repeat: { items: 'tabs', textField: 'tab_title' } } ],
|
|
40
|
+
divider: [ { control: 'html_tag', when: { look: 'line_text' }, requiresNonEmpty: 'text' } ],
|
|
41
|
+
'nested-accordion': [ { control: 'title_tag', repeat: { items: 'items', textField: 'item_title' } } ],
|
|
42
|
+
'link-in-bio': [
|
|
43
|
+
{ control: 'bio_heading_tag', requiresNonEmpty: 'bio_heading' },
|
|
44
|
+
{ control: 'bio_title_tag', requiresNonEmpty: 'bio_title' },
|
|
45
|
+
{ control: 'bio_about_tag', requiresNonEmpty: 'bio_about' },
|
|
46
|
+
],
|
|
47
|
+
// Pro — static / repeater
|
|
48
|
+
'animated-headline': [
|
|
49
|
+
{
|
|
50
|
+
control: 'tag',
|
|
51
|
+
requiresAnyNonEmpty: [ 'before_text', 'highlighted_text', 'rotating_text', 'after_text' ],
|
|
52
|
+
},
|
|
53
|
+
],
|
|
54
|
+
'author-box': [ { control: 'author_name_tag', requiresNonEmpty: 'author_name' } ],
|
|
55
|
+
'call-to-action': [
|
|
56
|
+
{ control: 'title_tag', requiresNonEmpty: 'title' },
|
|
57
|
+
{ control: 'description_tag', requiresNonEmpty: 'description' },
|
|
58
|
+
],
|
|
59
|
+
'flip-box': [
|
|
60
|
+
{ control: 'title_tag', requiresNonEmpty: 'title_text_a' },
|
|
61
|
+
{ control: 'description_tag', requiresNonEmpty: 'description_text_a' },
|
|
62
|
+
{ control: 'title_tag', requiresNonEmpty: 'title_text_b' },
|
|
63
|
+
{ control: 'description_tag', requiresNonEmpty: 'description_text_b' },
|
|
64
|
+
],
|
|
65
|
+
'price-list': [
|
|
66
|
+
{ control: 'title_tag', repeat: { items: 'price_list', textField: 'title' } },
|
|
67
|
+
{ control: 'description_tag', repeat: { items: 'price_list', textField: 'item_description' } },
|
|
68
|
+
],
|
|
69
|
+
'price-table': [ { control: 'heading_tag', requiresNonEmpty: 'heading' } ],
|
|
70
|
+
slides: [
|
|
71
|
+
{ control: 'slides_title_tag', repeat: { items: 'slides', textField: 'heading' } },
|
|
72
|
+
{ control: 'slides_description_tag', repeat: { items: 'slides', textField: 'description' } },
|
|
73
|
+
],
|
|
74
|
+
'table-of-contents': [ { control: 'html_tag', requiresNonEmpty: 'title' } ],
|
|
75
|
+
'video-playlist': [
|
|
76
|
+
{ control: 'playlist_title_tag', requiresNonEmpty: 'playlist_title' },
|
|
77
|
+
{
|
|
78
|
+
control: 'section_html_tag',
|
|
79
|
+
repeat: { items: 'tabs', tagSource: 'item', itemWhen: { type: 'section' }, textField: 'title' },
|
|
80
|
+
},
|
|
81
|
+
{
|
|
82
|
+
control: 'video_html_tag',
|
|
83
|
+
repeat: {
|
|
84
|
+
items: 'tabs',
|
|
85
|
+
tagSource: 'item',
|
|
86
|
+
itemWhen: { type: [ 'youtube', 'vimeo', 'hosted' ] },
|
|
87
|
+
textField: 'title',
|
|
88
|
+
},
|
|
89
|
+
},
|
|
90
|
+
],
|
|
91
|
+
sitemap: [ { control: 'sitemap_title_tag', repeat: { items: 'sitemap_items', textField: 'sitemap_title' } } ],
|
|
92
|
+
'loop-grid': [
|
|
93
|
+
{
|
|
94
|
+
control: 'nothing_found_message_html_tag',
|
|
95
|
+
when: { enable_nothing_found_message: 'yes' },
|
|
96
|
+
requiresNonEmpty: 'nothing_found_message_text',
|
|
97
|
+
},
|
|
98
|
+
],
|
|
99
|
+
search: [
|
|
100
|
+
{
|
|
101
|
+
control: 'nothing_found_message_html_tag',
|
|
102
|
+
when: { enable_nothing_found_message: 'yes' },
|
|
103
|
+
requiresNonEmpty: 'nothing_found_message_text',
|
|
104
|
+
},
|
|
105
|
+
],
|
|
106
|
+
// Pro — theme / Woo
|
|
107
|
+
posts: [ { control: 'title_tag', when: { show_title: 'yes' } } ],
|
|
108
|
+
'archive-posts': [ { control: 'title_tag', when: { show_title: 'yes' } } ],
|
|
109
|
+
portfolio: [ { control: 'title_tag', when: { show_title: 'yes' } } ],
|
|
110
|
+
'theme-post-title': [ { control: 'header_size' } ],
|
|
111
|
+
'theme-page-title': [ { control: 'header_size' } ],
|
|
112
|
+
'theme-archive-title': [ { control: 'header_size' } ],
|
|
113
|
+
'theme-site-title': [ { control: 'header_size' } ],
|
|
114
|
+
'woocommerce-product-title': [ { control: 'header_size' } ],
|
|
115
|
+
};
|
|
116
|
+
|
|
117
|
+
function isNonEmptyString( value: unknown ): boolean {
|
|
118
|
+
return typeof value === 'string' && value.trim() !== '';
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function matchesWhen( settings: Settings, when?: Record< string, string | string[] > ): boolean {
|
|
122
|
+
if ( ! when ) {
|
|
123
|
+
return true;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
return Object.entries( when ).every( ( [ key, expected ] ) => {
|
|
127
|
+
const actual = settings[ key ];
|
|
128
|
+
|
|
129
|
+
if ( Array.isArray( expected ) ) {
|
|
130
|
+
return expected.includes( String( actual ) );
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
return String( actual ) === expected;
|
|
134
|
+
} );
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function hasRequiredText( settings: Settings, key?: string ): boolean {
|
|
138
|
+
if ( ! key ) {
|
|
139
|
+
return true;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
return isNonEmptyString( settings[ key ] );
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function hasAnyRequiredText( settings: Settings, keys?: string[] ): boolean {
|
|
146
|
+
if ( ! keys?.length ) {
|
|
147
|
+
return true;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
return keys.some( ( key ) => isNonEmptyString( settings[ key ] ) );
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function parseHeadingLevel( raw: unknown ): number | null {
|
|
154
|
+
if ( typeof raw !== 'string' ) {
|
|
155
|
+
return null;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
const idx = HEADING_LEVELS.indexOf( raw.toLowerCase() as ( typeof HEADING_LEVELS )[ number ] );
|
|
159
|
+
|
|
160
|
+
return idx >= 0 ? idx + 1 : null;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function getRepeaterRows( settings: Settings, key: string ): Settings[] {
|
|
164
|
+
const rows = settings[ key ];
|
|
165
|
+
|
|
166
|
+
return Array.isArray( rows ) ? ( rows as Settings[] ) : [];
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
function pushHeading( headings: PageHeading[], elementId: string, raw: unknown ): void {
|
|
170
|
+
const level = parseHeadingLevel( raw );
|
|
171
|
+
|
|
172
|
+
if ( level !== null ) {
|
|
173
|
+
headings.push( { elementId, level } );
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
function extractHeadingsFromWidget( elementId: string, widgetType: string, settings: Settings ): PageHeading[] {
|
|
178
|
+
const descriptors = HEADING_CONTROLS_REGISTRY[ widgetType ];
|
|
179
|
+
|
|
180
|
+
if ( ! descriptors ) {
|
|
181
|
+
return [];
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
const headings: PageHeading[] = [];
|
|
185
|
+
|
|
186
|
+
for ( const descriptor of descriptors ) {
|
|
187
|
+
if ( ! matchesWhen( settings, descriptor.when ) ) {
|
|
188
|
+
continue;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
if ( ! hasRequiredText( settings, descriptor.requiresNonEmpty ) ) {
|
|
192
|
+
continue;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
if ( ! hasAnyRequiredText( settings, descriptor.requiresAnyNonEmpty ) ) {
|
|
196
|
+
continue;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
if ( descriptor.repeat ) {
|
|
200
|
+
const { items, textField, itemWhen, tagSource = 'widget' } = descriptor.repeat;
|
|
201
|
+
|
|
202
|
+
for ( const row of getRepeaterRows( settings, items ) ) {
|
|
203
|
+
if ( ! matchesWhen( row, itemWhen ) ) {
|
|
204
|
+
continue;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
if ( textField && ! hasRequiredText( row, textField ) ) {
|
|
208
|
+
continue;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
const raw = tagSource === 'item' ? row[ descriptor.control ] : settings[ descriptor.control ];
|
|
212
|
+
|
|
213
|
+
pushHeading( headings, elementId, raw );
|
|
214
|
+
}
|
|
215
|
+
} else {
|
|
216
|
+
pushHeading( headings, elementId, settings[ descriptor.control ] );
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
return headings;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
function extractPageHeadings( tree: Parameters< typeof walkElements >[ 0 ] ): PageHeading[] {
|
|
224
|
+
const headings: PageHeading[] = [];
|
|
225
|
+
|
|
226
|
+
walkElements( tree, ( node ) => {
|
|
227
|
+
if ( node.elType !== 'widget' || ! node.widgetType ) {
|
|
228
|
+
return;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
headings.push( ...extractHeadingsFromWidget( node.id, node.widgetType, node.settings ) );
|
|
232
|
+
} );
|
|
233
|
+
|
|
234
|
+
return headings;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
export const audit: Audit = {
|
|
238
|
+
id: 'audits/heading-structure',
|
|
239
|
+
title: __( 'Heading structure', 'elementor' ),
|
|
240
|
+
description: __( 'Pages should have exactly one H1 and a non-skipping heading order.', 'elementor' ),
|
|
241
|
+
fixHint: __( 'Ensure your page has one H1 and that heading levels do not skip (no H2 → H4).', 'elementor' ),
|
|
242
|
+
categories: [ 'seo', 'accessibility' ],
|
|
243
|
+
severity: 'error',
|
|
244
|
+
weight: 10,
|
|
245
|
+
evaluate: ( ctx ) => {
|
|
246
|
+
if ( ctx.elements.tree.length === 0 ) {
|
|
247
|
+
return { status: 'skipped', reason: __( 'No elements', 'elementor' ) };
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
const headings = extractPageHeadings( ctx.elements.tree );
|
|
251
|
+
|
|
252
|
+
const violations: AuditViolation[] = [];
|
|
253
|
+
|
|
254
|
+
if ( headings.length === 0 ) {
|
|
255
|
+
violations.push( {
|
|
256
|
+
auditId: audit.id,
|
|
257
|
+
label: __( 'No headings found on the page.', 'elementor' ),
|
|
258
|
+
} );
|
|
259
|
+
} else {
|
|
260
|
+
const h1Count = headings.filter( ( h ) => h.level === 1 ).length;
|
|
261
|
+
|
|
262
|
+
if ( h1Count === 0 ) {
|
|
263
|
+
violations.push( { auditId: audit.id, label: __( 'No H1 on the page.', 'elementor' ) } );
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
if ( h1Count > 1 ) {
|
|
267
|
+
headings
|
|
268
|
+
.filter( ( h ) => h.level === 1 )
|
|
269
|
+
.slice( 1 )
|
|
270
|
+
.forEach( ( h ) =>
|
|
271
|
+
violations.push( {
|
|
272
|
+
auditId: audit.id,
|
|
273
|
+
elementId: h.elementId,
|
|
274
|
+
targetHint: 'element-settings',
|
|
275
|
+
label: __( 'More than one H1 on the page.', 'elementor' ),
|
|
276
|
+
} )
|
|
277
|
+
);
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
for ( let i = 1; i < headings.length; i++ ) {
|
|
281
|
+
if ( headings[ i ].level - headings[ i - 1 ].level > 1 ) {
|
|
282
|
+
violations.push( {
|
|
283
|
+
auditId: audit.id,
|
|
284
|
+
elementId: headings[ i ].elementId,
|
|
285
|
+
targetHint: 'element-settings',
|
|
286
|
+
label: __( 'Heading level skipped.', 'elementor' ),
|
|
287
|
+
} );
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
return violations.length === 0 ? { status: 'pass' } : { status: 'fail', violations };
|
|
293
|
+
},
|
|
294
|
+
};
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { __ } from '@wordpress/i18n';
|
|
2
|
+
|
|
3
|
+
import { type Audit, type AuditViolation } from '../types';
|
|
4
|
+
import { walkElements } from '../utils/walk';
|
|
5
|
+
|
|
6
|
+
function isHiddenOnAllDevices( settings: Record< string, unknown > ): boolean {
|
|
7
|
+
return Boolean( settings.hide_desktop ) && Boolean( settings.hide_tablet ) && Boolean( settings.hide_mobile );
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export const audit: Audit = {
|
|
11
|
+
id: 'audits/hidden-elements',
|
|
12
|
+
title: __( 'Hidden elements', 'elementor' ),
|
|
13
|
+
description: __(
|
|
14
|
+
'Elements hidden on all devices simultaneously are effectively dead code in the page tree.',
|
|
15
|
+
'elementor'
|
|
16
|
+
),
|
|
17
|
+
fixHint: __( 'Remove the element or make it visible on at least one device breakpoint.', 'elementor' ),
|
|
18
|
+
categories: [ 'best-practices', 'performance' ],
|
|
19
|
+
severity: 'info',
|
|
20
|
+
weight: 3,
|
|
21
|
+
evaluate: ( ctx ) => {
|
|
22
|
+
if ( ctx.elements.tree.length === 0 ) {
|
|
23
|
+
return { status: 'skipped', reason: __( 'No elements', 'elementor' ) };
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const violations: AuditViolation[] = [];
|
|
27
|
+
|
|
28
|
+
walkElements( ctx.elements.tree, ( node ) => {
|
|
29
|
+
if ( isHiddenOnAllDevices( node.settings ) ) {
|
|
30
|
+
violations.push( {
|
|
31
|
+
auditId: audit.id,
|
|
32
|
+
elementId: node.id,
|
|
33
|
+
targetHint: 'element-settings',
|
|
34
|
+
label: __( 'Element is hidden on all devices.', 'elementor' ),
|
|
35
|
+
} );
|
|
36
|
+
}
|
|
37
|
+
} );
|
|
38
|
+
|
|
39
|
+
return violations.length === 0 ? { status: 'pass' } : { status: 'fail', violations };
|
|
40
|
+
},
|
|
41
|
+
};
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { __ } from '@wordpress/i18n';
|
|
2
|
+
|
|
3
|
+
import { type Audit, type AuditViolation } from '../types';
|
|
4
|
+
import { hasMeaningfulAlt, isImageSourcePresent } from '../utils/image-alt';
|
|
5
|
+
import { hasPageImages, walkImageLikeSources } from '../utils/image-like-sources';
|
|
6
|
+
|
|
7
|
+
export const audit: Audit = {
|
|
8
|
+
id: 'audits/images-alt-text',
|
|
9
|
+
title: __( 'Images alt text', 'elementor' ),
|
|
10
|
+
description: __( 'Every image needs a meaningful alt attribute for screen readers and image-search.', 'elementor' ),
|
|
11
|
+
fixHint: __( "Open the image's settings and add an Alt Text describing the image.", 'elementor' ),
|
|
12
|
+
categories: [ 'seo', 'accessibility' ],
|
|
13
|
+
severity: 'error',
|
|
14
|
+
weight: 10,
|
|
15
|
+
evaluate: ( ctx ) => {
|
|
16
|
+
if ( ! hasPageImages( ctx.elements.tree ) ) {
|
|
17
|
+
return { status: 'skipped', reason: __( 'No images', 'elementor' ) };
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const violations: AuditViolation[] = [];
|
|
21
|
+
const failedWidgetIds = new Set< string >();
|
|
22
|
+
let missingAltImageCount = 0;
|
|
23
|
+
|
|
24
|
+
walkImageLikeSources( ctx.elements.tree, ( { node, media } ) => {
|
|
25
|
+
if ( ! isImageSourcePresent( media ) || hasMeaningfulAlt( media, ctx.pageContext ) ) {
|
|
26
|
+
return;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
missingAltImageCount++;
|
|
30
|
+
|
|
31
|
+
if ( ! failedWidgetIds.has( node.id ) ) {
|
|
32
|
+
failedWidgetIds.add( node.id );
|
|
33
|
+
violations.push( {
|
|
34
|
+
auditId: audit.id,
|
|
35
|
+
elementId: node.id,
|
|
36
|
+
targetHint: 'element-settings',
|
|
37
|
+
label: __( 'Image is missing alt text.', 'elementor' ),
|
|
38
|
+
externalUrl: ctx.pageContext.ally_plugin_url,
|
|
39
|
+
} );
|
|
40
|
+
}
|
|
41
|
+
} );
|
|
42
|
+
|
|
43
|
+
return violations.length === 0
|
|
44
|
+
? { status: 'pass' }
|
|
45
|
+
: { status: 'fail', violations, metadata: { missingAltImageCount } };
|
|
46
|
+
},
|
|
47
|
+
};
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { __, sprintf } from '@wordpress/i18n';
|
|
2
|
+
|
|
3
|
+
import { type Audit, type AuditViolation } from '../types';
|
|
4
|
+
import { hasPageImages, walkImageLikeSources } from '../utils/image-like-sources';
|
|
5
|
+
|
|
6
|
+
const SIZE_THRESHOLD_BYTES = 500 * 1024;
|
|
7
|
+
const BYTES_PER_KB = 1024;
|
|
8
|
+
|
|
9
|
+
export const audit: Audit = {
|
|
10
|
+
id: 'audits/images-too-large',
|
|
11
|
+
title: __( 'Oversized images', 'elementor' ),
|
|
12
|
+
description: __( 'Large image files slow down the page.', 'elementor' ),
|
|
13
|
+
fixHint: __( 'Replace the image with a smaller version or enable image optimization.', 'elementor' ),
|
|
14
|
+
categories: [ 'performance' ],
|
|
15
|
+
severity: 'warning',
|
|
16
|
+
weight: 7,
|
|
17
|
+
evaluate: ( ctx ) => {
|
|
18
|
+
if ( ! hasPageImages( ctx.elements.tree ) ) {
|
|
19
|
+
return { status: 'skipped', reason: __( 'No images', 'elementor' ) };
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const widgetMaxKb = new Map< string, number >();
|
|
23
|
+
let oversizedImageCount = 0;
|
|
24
|
+
|
|
25
|
+
walkImageLikeSources( ctx.elements.tree, ( { node, media } ) => {
|
|
26
|
+
const id = media.id;
|
|
27
|
+
|
|
28
|
+
if ( ! id ) {
|
|
29
|
+
return;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const size = ctx.pageContext.image_sizes[ id ];
|
|
33
|
+
|
|
34
|
+
if ( ! size || size.filesize_bytes <= SIZE_THRESHOLD_BYTES ) {
|
|
35
|
+
return;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
oversizedImageCount++;
|
|
39
|
+
|
|
40
|
+
const kb = Math.round( size.filesize_bytes / BYTES_PER_KB );
|
|
41
|
+
const currentMax = widgetMaxKb.get( node.id ) ?? 0;
|
|
42
|
+
widgetMaxKb.set( node.id, Math.max( currentMax, kb ) );
|
|
43
|
+
} );
|
|
44
|
+
|
|
45
|
+
const violations: AuditViolation[] = Array.from( widgetMaxKb.entries() ).map( ( [ elementId, kb ] ) => ( {
|
|
46
|
+
auditId: audit.id,
|
|
47
|
+
elementId,
|
|
48
|
+
targetHint: 'element-settings' as const,
|
|
49
|
+
label: sprintf(
|
|
50
|
+
/* translators: %d is the image file size in kilobytes. */
|
|
51
|
+
__( 'Image is %d KB (over 500 KB).', 'elementor' ),
|
|
52
|
+
kb
|
|
53
|
+
),
|
|
54
|
+
externalUrl: ctx.pageContext.image_optimization_plugin_url,
|
|
55
|
+
} ) );
|
|
56
|
+
|
|
57
|
+
return violations.length === 0
|
|
58
|
+
? { status: 'pass' }
|
|
59
|
+
: { status: 'fail', violations, metadata: { oversizedImageCount } };
|
|
60
|
+
},
|
|
61
|
+
};
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { __ } from '@wordpress/i18n';
|
|
2
|
+
|
|
3
|
+
import { type Audit, type AuditViolation } from '../types';
|
|
4
|
+
import { walkElements } from '../utils/walk';
|
|
5
|
+
|
|
6
|
+
function isBoxed( settings: Record< string, unknown > ): boolean {
|
|
7
|
+
return settings.content_width === 'boxed';
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export const audit: Audit = {
|
|
11
|
+
id: 'audits/nested-boxed-containers',
|
|
12
|
+
title: __( 'Boxed container nested inside a boxed parent', 'elementor' ),
|
|
13
|
+
description: __( 'An inner container does not need to be boxed when its parent already is.', 'elementor' ),
|
|
14
|
+
fixHint: __( "Change the inner container's content width to Full Width.", 'elementor' ),
|
|
15
|
+
categories: [ 'best-practices', 'performance' ],
|
|
16
|
+
severity: 'info',
|
|
17
|
+
weight: 5,
|
|
18
|
+
evaluate: ( ctx ) => {
|
|
19
|
+
if ( ctx.elements.tree.length === 0 ) {
|
|
20
|
+
return { status: 'skipped', reason: __( 'No elements', 'elementor' ) };
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const violations: AuditViolation[] = [];
|
|
24
|
+
|
|
25
|
+
walkElements( ctx.elements.tree, ( node, parents ) => {
|
|
26
|
+
if ( node.elType !== 'container' || ! isBoxed( node.settings ) ) {
|
|
27
|
+
return;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const nearestContainerAncestor = [ ...parents ].reverse().find( ( p ) => p.elType === 'container' );
|
|
31
|
+
|
|
32
|
+
if ( nearestContainerAncestor && isBoxed( nearestContainerAncestor.settings ) ) {
|
|
33
|
+
violations.push( {
|
|
34
|
+
auditId: audit.id,
|
|
35
|
+
elementId: node.id,
|
|
36
|
+
targetHint: 'element-settings',
|
|
37
|
+
label: __( 'Nested boxed container.', 'elementor' ),
|
|
38
|
+
} );
|
|
39
|
+
}
|
|
40
|
+
} );
|
|
41
|
+
|
|
42
|
+
return violations.length === 0 ? { status: 'pass' } : { status: 'fail', violations };
|
|
43
|
+
},
|
|
44
|
+
};
|
|
@@ -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/page-excerpt',
|
|
7
|
+
title: __( 'Page excerpt', 'elementor' ),
|
|
8
|
+
description: __( 'A descriptive excerpt helps search engines and previews summarize the page.', 'elementor' ),
|
|
9
|
+
fixHint: __( 'Open Page Settings and write a short excerpt.', 'elementor' ),
|
|
10
|
+
categories: [ 'seo' ],
|
|
11
|
+
severity: 'warning',
|
|
12
|
+
weight: 5,
|
|
13
|
+
evaluate: ( ctx ) => {
|
|
14
|
+
if ( ctx.pageContext.post_excerpt ) {
|
|
15
|
+
return { status: 'pass' };
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
return {
|
|
19
|
+
status: 'fail',
|
|
20
|
+
violations: [
|
|
21
|
+
{
|
|
22
|
+
auditId: audit.id,
|
|
23
|
+
label: __( 'Page has no excerpt.', 'elementor' ),
|
|
24
|
+
targetHint: 'page-settings',
|
|
25
|
+
angieFix: true,
|
|
26
|
+
},
|
|
27
|
+
],
|
|
28
|
+
};
|
|
29
|
+
},
|
|
30
|
+
};
|