• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

Yoast / wordpress-seo / b02bddd11cd155f6703e6a5b838c9d434fa06b2f

05 Jan 2026 11:40AM UTC coverage: 53.308% (-0.009%) from 53.317%
b02bddd11cd155f6703e6a5b838c9d434fa06b2f

push

github

web-flow
Merge pull request #22817 from Yoast/4573-wp-68-compatibility-check-calls-to-getblocks-on-pages-and-posts

Use correct method to retrieve content blocks for the highlighting feature and Content blocks collapsible

8770 of 16275 branches covered (53.89%)

Branch coverage included in aggregate %.

10 of 23 new or added lines in 4 files covered. (43.48%)

1 existing line in 1 file now uncovered.

32859 of 61816 relevant lines covered (53.16%)

46645.45 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

51.26
/packages/js/src/analysis/blockEditorData.js
1
/* eslint-disable complexity */
2
import { getBlockContent } from "@wordpress/blocks";
3
import { select, subscribe } from "@wordpress/data";
4
import { actions } from "@yoast/externals/redux";
5
import { debounce } from "lodash";
6
import { languageProcessing } from "yoastseo";
7
import { reapplyAnnotationsForSelectedBlock } from "../decorator/gutenberg";
8
import { excerptFromContent, fillReplacementVariables, mapCustomFields, mapCustomTaxonomies } from "../helpers/replacementVariableHelpers";
9
import getContentLocale from "./getContentLocale";
10

11
const {
12
        updateReplacementVariable,
13
        updateData,
14
        hideReplacementVariables,
15
        setContentImage,
16
        updateSettings,
17
        setEditorDataContent,
18
        setEditorDataTitle,
19
        setEditorDataExcerpt,
20
        setEditorDataImageUrl,
21
        setEditorDataSlug,
22
} = actions;
2✔
23

24
const $ = global.jQuery;
2✔
25

26
/**
27
 * Represents the data.
28
 */
29
export default class BlockEditorData {
30
        /**
31
         * Sets the wp data, Yoast SEO refresh function and data object.
32
         *
33
         * @param {Function} refresh The YoastSEO refresh function.
34
         * @param {Object} store     The YoastSEO Redux store.
35
         * @returns {void}
36
         */
37
        constructor( refresh, store ) {
38
                this._refresh = refresh;
2✔
39
                this._store = store;
2✔
40
                this._data = {};
2✔
41
                this.getPostAttribute = this.getPostAttribute.bind( this );
2✔
42
                this.refreshYoastSEO = this.refreshYoastSEO.bind( this );
2✔
43
        }
44

45
        /**
46
         * Initializes this Gutenberg data instance.
47
         *
48
         * @param {Object} replaceVars The replacevars.
49
         * @param {string[]} hiddenReplaceVars The replacement variables passed in the wp-seo-post-scraper args.
50
         *
51
         * @returns {void}
52
         */
53
        initialize( replaceVars, hiddenReplaceVars = [] ) {
×
54
                // Fill data object on page load.
55
                this._data = this.getInitialData( replaceVars );
×
56
                fillReplacementVariables( this._data, this._store );
×
57
                this._store.dispatch( hideReplacementVariables( hiddenReplaceVars ) );
×
58
                this.subscribeToGutenberg();
×
59
                this.subscribeToYoastSEO();
×
60
        }
61

62
        /**
63
         * Retrieves the initial data.
64
         *
65
         * @param {Object} replaceVars The replacevars.
66
         *
67
         * @returns {Object} The initial data.
68
         */
69
        getInitialData( replaceVars ) {
70
                const gutenbergData = this.collectGutenbergData();
×
71

72
                // Custom_fields and custom_taxonomies are objects instead of strings, which causes console errors.
73
                replaceVars = mapCustomFields( replaceVars, this._store );
×
74
                replaceVars = mapCustomTaxonomies( replaceVars, this._store );
×
75

76
                return {
×
77
                        ...replaceVars,
78
                        ...gutenbergData,
79
                };
80
        }
81

82
        /**
83
         * Sets the refresh function.
84
         *
85
         * @param {Function} refresh The refresh function.
86
         *
87
         * @returns {void}
88
         */
89
        setRefresh( refresh ) {
90
                this._refresh = refresh;
2✔
91
        }
92

93
        /**
94
         * Checks whether the current data and the Gutenberg data are the same.
95
         *
96
         * @param {Object} currentData The current data.
97
         * @param {Object} gutenbergData The data from Gutenberg.
98
         *
99
         * @returns {boolean} Whether the current data and the gutenbergData is the same.
100
         */
101
        isShallowEqual( currentData, gutenbergData ) {
102
                if ( Object.keys( currentData ).length !== Object.keys( gutenbergData ).length ) {
10✔
103
                        return false;
2✔
104
                }
105

106
                for ( const dataPoint in currentData ) {
8✔
107
                        if ( currentData.hasOwnProperty( dataPoint ) ) {
28!
108
                                if ( ! ( dataPoint in gutenbergData ) || currentData[ dataPoint ] !== gutenbergData[ dataPoint ] ) {
28✔
109
                                        return false;
4✔
110
                                }
111
                        }
112
                }
113
                return true;
4✔
114
        }
115

116
        /**
117
         * Gets the media data by id.
118
         *
119
         * @param {number} mediaId The media item id.
120
         *
121
         * @returns {Object} The media object.
122
         */
123
        getMediaById( mediaId ) {
124
                if ( ! this._coreDataSelect ) {
×
125
                        this._coreDataSelect = select( "core" );
×
126
                }
127

128
                return this._coreDataSelect.getMedia( mediaId );
×
129
        }
130

131
        /**
132
         * Retrieves the Gutenberg data for the passed post attribute.
133
         *
134
         * @param {string} attribute The post attribute you'd like to retrieve.
135
         *
136
         * @returns {string|number} The post attribute.
137
         */
138
        getPostAttribute( attribute ) {
139
                if ( ! this._coreEditorSelect ) {
42✔
140
                        this._coreEditorSelect = select( "core/editor" );
2✔
141
                }
142

143
                return this._coreEditorSelect.getEditedPostAttribute( attribute );
42✔
144
        }
145

146
        /**
147
         * Get the post's slug.
148
         *
149
         * @returns {string} The post's slug.
150
         */
151
        getSlug() {
152
                /**
153
                 * Before the post has been saved for the first time, the generated_slug is "auto-draft".
154
                 *
155
                 * Before the post is saved the post status is "auto-draft", so when this is the case the slug
156
                 * should be empty.
157
                 */
158
                if ( this.getPostAttribute( "status" ) === "auto-draft" ) {
8!
159
                        return "";
×
160
                }
161

162
                let generatedSlug = this.getPostAttribute( "generated_slug" ) || "";
8!
163

164
                /**
165
                 * This should be removed when the following issue is resolved:
166
                 *
167
                 * https://github.com/WordPress/gutenberg/issues/8770
168
                 */
169
                if ( generatedSlug === "auto-draft" ) {
8!
170
                        generatedSlug = "";
×
171
                }
172

173
                // When no custom slug is provided we should use the generated_slug attribute.
174
                const slug = this.getPostAttribute( "slug" ) || generatedSlug;
8!
175
                try {
8✔
176
                        return decodeURI( slug );
8✔
177
                } catch ( e ) {
178
                        return slug;
×
179
                }
180
        }
181

182
        /**
183
         * Gets the base url from the permalink parts.
184
         *
185
         * @returns {string} The base url.
186
         */
187
        getPostBaseUrl() {
188
                const permalinkParts = select( "core/editor" ).getPermalinkParts();
8✔
189
                if ( permalinkParts === null || ! permalinkParts?.prefix ) {
8!
190
                        // Fallback on the base url retrieved from the wpseoScriptData.
191
                        return window.wpseoScriptData.metabox.base_url;
×
192
                }
193

194
                let baseUrl = permalinkParts.prefix;
8✔
195
                const isAutoDraft = select( "core/editor" ).isEditedPostNew();
8✔
196
                if ( isAutoDraft ) {
8!
197
                        // For post auto-drafts, the `baseUrl` includes the `?={ID}` that we do not want.
198
                        try {
×
199
                                const url = new URL( baseUrl );
×
200
                                baseUrl = url.origin + url.pathname;
×
201
                        } catch ( e ) {
202
                                // Ignore this error.
203
                        }
204
                }
205

206
                // Enforce ending with a slash because of the internal handling in the SnippetEditor component.
207
                if ( ! baseUrl.endsWith( "/" ) ) {
8!
208
                        baseUrl += "/";
×
209
                }
210

211
                return baseUrl;
8✔
212
        }
213

214
        /**
215
         * Collects the data of a post from Gutenberg.
216
         *
217
         * @returns {{content: string, title: string, slug: string, excerpt: string, excerpt_only: string,
218
         *                         snippetPreviewImageURL: string, contentImage: string, baseUrl: string}} The collected data.
219
         */
220
        collectGutenbergData() {
221
                let content = select( "core/editor" ).getEditedPostContent();
8✔
222

223
                // Gutenberg applies the `autop` function under the hood to all Classic (core/freeform) blocks.
224
                // The most likely situation for these to appear in posts is through converting a post from Classic to Block editor.
225
                // We account for that below, but not for the (unlikely) case when a Classic block is added to a post consisting of other blocks.
226
                const blocks = select( "core/editor" ).getEditorBlocks();
8✔
227
                if ( blocks.length === 1 && blocks[ 0 ].name === "core/freeform" ) {
8✔
228
                        content = getBlockContent( blocks[ 0 ] );
2✔
229
                }
230

231
                const contentImage = this.calculateContentImage( content );
8✔
232
                const excerpt = this.getPostAttribute( "excerpt" ) || "";
8!
233

234
                return {
8✔
235
                        content,
236
                        title: this.getPostAttribute( "title" ) || "",
4!
237
                        slug: this.getSlug(),
238
                        excerpt: excerpt || excerptFromContent( content, getContentLocale() === "ja" ? 80 : 156 ),
4!
239
                        // eslint-disable-next-line camelcase
240
                        excerpt_only: excerpt,
241
                        snippetPreviewImageURL: this.getFeaturedImage() || contentImage,
4!
242
                        contentImage,
243
                        baseUrl: this.getPostBaseUrl(),
244
                };
245
        }
246

247
        /**
248
         * Gets the source URL for the featured image.
249
         *
250
         * @returns {null|string} The source URL.
251
         */
252
        getFeaturedImage() {
253
                const featuredImage = this.getPostAttribute( "featured_media" );
×
254
                if ( featuredImage ) {
×
255
                        const mediaObj = this.getMediaById( featuredImage );
×
256

257
                        if ( mediaObj ) {
×
258
                                return mediaObj.source_url;
×
259
                        }
260
                }
261

262
                return null;
×
263
        }
264

265
        /**
266
         * Returns the image from the content.
267
         *
268
         * @param {string} content The content.
269
         *
270
         * @returns {string} The first image found in the content.
271
         */
272
        calculateContentImage( content ) {
273
                const images = languageProcessing.imageInText( content );
8✔
274

275
                if ( images.length === 0 ) {
8!
276
                        return "";
8✔
277
                }
278

279
                const imageElements = $.parseHTML( images.join( "" ) );
×
280

281
                for ( const imageElement of imageElements ) {
×
282
                        if ( imageElement.src ) {
×
283
                                return imageElement.src;
×
284
                        }
285
                }
286

287
                return "";
×
288
        }
289

290
        /**
291
         * Updates the redux store with the changed data.
292
         *
293
         * @param {Object} newData The changed data.
294
         *
295
         * @returns {void}
296
         */
297
        handleEditorChange( newData ) {
298
                // Handle content change.
299
                if ( this._data.content !== newData.content ) {
2!
300
                        this._store.dispatch( setEditorDataContent( newData.content ) );
2✔
301
                }
302
                // Handle title change.
303
                if ( this._data.title !== newData.title ) {
2!
304
                        this._store.dispatch( setEditorDataTitle( newData.title ) );
2✔
305
                        this._store.dispatch( updateReplacementVariable( "title", newData.title ) );
2✔
306
                }
307
                // Handle excerpt change.
308
                if ( this._data.excerpt !== newData.excerpt ) {
2!
309
                        this._store.dispatch( setEditorDataExcerpt( newData.excerpt ) );
2✔
310
                        this._store.dispatch( updateReplacementVariable( "excerpt", newData.excerpt ) );
2✔
311
                        this._store.dispatch( updateReplacementVariable( "excerpt_only", newData.excerpt_only ) );
2✔
312
                }
313
                // Handle slug change.
314
                if ( this._data.slug !== newData.slug ) {
2!
315
                        this._store.dispatch( setEditorDataSlug( newData.slug ) );
2✔
316
                        this._store.dispatch( updateData( { slug: newData.slug } ) );
2✔
317
                }
318
                // Handle snippet preview image change.
319
                if ( this._data.snippetPreviewImageURL !== newData.snippetPreviewImageURL ) {
2!
320
                        this._store.dispatch( setEditorDataImageUrl( newData.snippetPreviewImageURL ) );
2✔
321
                        this._store.dispatch( updateData( { snippetPreviewImageURL: newData.snippetPreviewImageURL } ) );
2✔
322
                }
323
                // Handle content image change.
324
                if ( this._data.contentImage !== newData.contentImage ) {
2!
325
                        this._store.dispatch( setContentImage( newData.contentImage ) );
2✔
326
                }
327
                // Handle base URL change.
328
                if ( this._data.baseUrl !== newData.baseUrl ) {
2!
329
                        this._store.dispatch( updateSettings( { baseUrl: newData.baseUrl } ) );
2✔
330
                }
331
        }
332

333
        /**
334
         * If a marker is active, find the associated assessment result and applies the marker on that result.
335
         *
336
         * @returns {void}
337
         */
338
        reapplyMarkers() {
339
                const {
340
                        getActiveMarker,
341
                        getMarkerPauseStatus,
342
                } = select( "yoast-seo/editor" );
×
343

344
                const activeMarker = getActiveMarker();
×
345
                const isMarkerPaused = getMarkerPauseStatus();
×
346

347
                if ( ! activeMarker || isMarkerPaused ) {
×
348
                        return;
×
349
                }
350

351
                reapplyAnnotationsForSelectedBlock();
×
352
        }
353

354
        /**
355
         * Refreshes YoastSEO's app when the Gutenberg data is dirty.
356
         *
357
         * @returns {void}
358
         */
359
        refreshYoastSEO() {
360
                const gutenbergData = this.collectGutenbergData();
4✔
361

362
                // Set isDirty to true if the current data and Gutenberg data are unequal.
363
                const isDirty = ! this.isShallowEqual( this._data, gutenbergData );
4✔
364

365
                if ( isDirty ) {
4✔
366
                        this.handleEditorChange( gutenbergData );
2✔
367
                        this._data = gutenbergData;
2✔
368
                        this._refresh();
2✔
369
                }
370
        }
371

372
        /**
373
         * Checks whether new analysis results are available in the store.
374
         *
375
         * @returns {boolean} Whether new analysis results are available.
376
         */
377
        areNewAnalysisResultsAvailable() {
378
                const yoastSeoEditorSelectors = select( "yoast-seo/editor" );
×
379
                const readabilityResults = yoastSeoEditorSelectors.getReadabilityResults();
×
380
                const seoResults = yoastSeoEditorSelectors.getResultsForFocusKeyword();
×
381

382
                if (
×
383
                        this._previousReadabilityResults !== readabilityResults ||
×
384
                        this._previousSeoResults !== seoResults
385
                ) {
386
                        this._previousReadabilityResults = readabilityResults;
×
387
                        this._previousSeoResults = seoResults;
×
388
                        return true;
×
389
                }
390

391
                return false;
×
392
        }
393

394
        /**
395
         * Reapplies the markers when new analysis results are available.
396
         *
397
         * @returns {void}
398
         */
399
        onNewAnalysisResultsAvailable() {
400
                this.reapplyMarkers();
×
401
        }
402

403
        /**
404
         * Listens to the Gutenberg data and rendering mode changes.
405
         *
406
         * @returns {void}
407
         */
408
        subscribeToGutenberg() {
NEW
409
                this._previousRenderingMode = select( "core/editor" ).getRenderingMode && select( "core/editor" ).getRenderingMode();
×
NEW
410
                this.subscriber = debounce( () => {
×
NEW
411
                        const currentRenderingMode = select( "core/editor" ).getRenderingMode && select( "core/editor" ).getRenderingMode();
×
NEW
412
                        if ( currentRenderingMode !== this._previousRenderingMode ) {
×
NEW
413
                                this._previousRenderingMode = currentRenderingMode;
×
NEW
414
                                this._refresh();
×
NEW
415
                                return;
×
416
                        }
NEW
417
                        this.refreshYoastSEO();
×
418
                }, 500 );
UNCOV
419
                subscribe( this.subscriber );
×
420
        }
421

422
        /**
423
         * Listens to the analysis data.
424
         *
425
         * If the analysisData has changed this.onNewAnalysisResultsAvailable() is called.
426
         *
427
         * @returns {void}
428
         */
429
        subscribeToYoastSEO() {
430
                this.yoastSubscriber = () => {
×
431
                        if ( this.areNewAnalysisResultsAvailable() ) {
×
432
                                this.onNewAnalysisResultsAvailable();
×
433
                        }
434
                };
435
                subscribe( this.yoastSubscriber );
×
436
        }
437

438
        /**
439
         * Returns the data and whether the data is dirty.
440
         *
441
         * @returns {Object} The data and whether the data is dirty.
442
         */
443
        getData() {
444
                return this._data;
2✔
445
        }
446
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc