Skip to content

1376 bulk editor collect ai prompt content client side via yoastseo instead of the php mirror - #23524

Open
FAMarfuaty wants to merge 9 commits into
feature/bulk-editor-ph2from
1376-bulk-editor-collect-ai-prompt-content-client-side-via-yoastseo-instead-of-the-php-mirror
Open

1376 bulk editor collect ai prompt content client side via yoastseo instead of the php mirror#23524
FAMarfuaty wants to merge 9 commits into
feature/bulk-editor-ph2from
1376-bulk-editor-collect-ai-prompt-content-client-side-via-yoastseo-instead-of-the-php-mirror

Conversation

@FAMarfuaty

@FAMarfuaty FAMarfuaty commented Jul 30, 2026

Copy link
Copy Markdown
Contributor

Context

  • Premium's AI bulk suggestions built their prompt content server-side, in a PHP re-implementation of the yoastseo tokenizer that had already drifted from the in-editor AI generator (mid-sentence cuts, a flat token cap, a duplicated tokenizer). Part of Yoast/reserved-tasks#1376.
  • This is the Free half: it moves the prompt-content machinery into shared code, exposes it to Premium, and adds the endpoint Premium needs to collect content in the browser. Premium's half is Yoast/wordpress-seo-premium#5044.

Summary

This PR can be summarized in the following changelog entry:

  • [wordpress-seo-premium] Fixes a bug where the AI bulk suggestions prompt content was cut off mid-sentence and ignored the lower token budget for products, which was caused by the prompt content being rebuilt server-side instead of by the analysis engine.

Relevant technical choices:

  • collectPromptContent() and the 300/150 token budgets moved to shared-admin/helpers so the in-editor generator and the bulk editor share one definition. prepare-prompt-content.js keeps using the analysis worker to obtain paragraphs — only the accumulation is shared, so the in-editor flow is behaviourally untouched and its existing tests guard that.
  • The bulk editor runs the research on the main thread via ensureTree() instead of spinning up an analysis worker. The worker's runResearch does nothing more than this rebind-and-call, and a worker round trip per post would cost more than the 2–3 ms the parse actually takes.
  • Premium gets this over the existing window.yoast.bulkEditor bridge rather than a new yoastseo export. That keeps the surface out of the package's public API, and keeps the analysis package out of Premium's bundle entirely — Premium imports nothing from yoastseo.
  • posts_content is a separate route rather than a flag on the posts route: the table listing never needs content, so this keeps it out of every page load. Inaccessible IDs are omitted rather than failing the request, so one locked post cannot cost the caller its whole batch.
  • The registered shortcode tags are now localised on the bulk editor page. This is what lets the parser strip shortcode delimiters while keeping the text they enclose — strip_shortcodes() deletes the enclosed text, so without this a post whose body sits inside a [caption] would have sent nothing at all.
  • getVisibleContentLength also moved to shared-admin and onto the bridge, so Premium measures "limited content" with the same helper as the in-editor tip rather than a server-side approximation that strips markup differently.

Test instructions

Test instructions for the acceptance test before the PR gets merged

Prerequisites

  • Keep DevTools open (Network + Console).
  • The user-visible behaviour of this PR lives in Premium: test it together with Yoast/wordpress-seo-premium#5044, using that PR's grouped instructions. The steps below cover what is testable on the Free side alone.

A. The new content route

A1. On the bulk editor page, grab a nonce from the console with window.wpseoBulkEditorData.nonce, then request:
/wp-json/yoast/v1/bulk_editor/posts_content?ids=<id1>,<id2>

Before this PR After this PR
Route Did not exist (404) Returns { posts: [ { id, content } ] }
content The raw, unrendered post_content, block comments and shortcodes intact

A2. Confirm the table page itself does not request content: reload the bulk editor and check that posts_content is only called when Premium generates, never on page load.

B. The bridge exposed to Premium

B1. In the console on the bulk editor page, check:

Object.keys( window.yoast.bulkEditor.helpers )   // preparePromptContent, getVisibleContentLength
window.yoast.bulkEditor.constants                // { MAX_TOKENS_DEFAULT: 300, MAX_TOKENS_IRREGULAR: 150 }
window.wpseoBulkEditorData.analysis.shortcodes   // the registered tags: caption, gallery, …
Before this PR After this PR
helpers / constants absent; no shortcode list localised All present, and the existing components / hooks bridge entries still there

B2. Confirm the shortcode list makes the parser keep enclosed text:

await window.yoast.bulkEditor.helpers.preparePromptContent( "[caption]Wrapped wording stays.[/caption]" )

Expected: "Wrapped wording stays." — the text without the brackets. Before this PR there was nothing to call.

C. Unhappy paths

C1. No permission. As an Author, request posts_content for a post owned by someone else. That ID must be absent from posts while the others are returned — it must not fail the whole request.

C2. Not logged in / insufficient capability. Request the route while logged out. Expect a 401/403, not data.

C3. Deleted or invalid IDs. Request with a non-existent ID (e.g. 999999) mixed with a valid one. The valid one comes back, the missing one is simply absent.

C4. Schema limits. Request with 21 IDs and confirm a 400 (max 20). Request with no ids and confirm a 400 (required).

C5. Unparseable content. Call preparePromptContent with junk, e.g. "<p>unclosed <div>", "" and null. It must resolve to a string and never throw; an empty result comes back as ".".

D. Regressions to watch

D1. In-editor AI generator. Open a post with a focus keyphrase, click Generate meta description, and confirm suggestions still appear and are based on the opening sentences. Its accumulation loop and the 300/150 budgets moved to shared code, so this is the main thing to re-check.

D2. In-editor AI tip. Confirm the "not enough content" tip still appears for a very short post and not for a long one — getVisibleContentLength moved modules.

D3. Bulk editor field re-scoring. Edit an SEO title or meta description in the bulk editor and confirm its score still updates. The researcher singleton it uses was extracted into its own module and is now shared with the prompt-content service.

D4. Products and terms. In the post editor for a product (with WooCommerce) and for a term, confirm the in-editor generator still applies the lower 150-token budget.

Relevant test scenarios

  • Changes should be tested with the browser console open
  • Changes should be tested on different posts/pages/taxonomies/custom post types/custom taxonomies
  • Changes should be tested on different editors (Default Block/Gutenberg/Classic/Elementor/other)
  • Changes should be tested on different browsers
  • Changes should be tested on multisite

The console is needed for the bridge checks (B) and because the prompt-content service parses on the main thread — a parser error surfaces there rather than in the UI. Content types matter for D4, where products and terms take a different token budget.

Test instructions for QA when the code is in the RC

  • QA should use the same steps as above.

QA can test this PR by following these steps:

  • Sections A–C are developer-level checks against the REST route and the browser console. For QA the meaningful coverage is section D — that the in-editor AI generator, the in-editor "not enough content" tip, and bulk editor field re-scoring all behave exactly as before — plus the paired Premium PR Yoast/wordpress-seo-premium#5044.

Impact check

This PR affects the following parts of the plugin, which may require extra testing:

  • The in-editor AI generator's prompt content — its accumulation loop and token budgets moved to shared code. Behaviour should be unchanged; worth a regression pass on posts, pages and terms, and on products with WooCommerce active (150-token budget).
  • The in-editor AI "limited content" tip — getVisibleContentLength moved modules. Same function, new location.
  • The bulk editor page's field re-scoring — the researcher singleton it used was extracted into its own module and is now shared with the prompt-content service. Editing an SEO title or meta description in the bulk editor should still update its score.

Other environments

  • This PR also affects Shopify. I have added a changelog entry starting with [shopify-seo], added test instructions for Shopify and attached the Shopify label to this PR.
  • This PR also affects Yoast SEO for Google Docs. I have added a changelog entry starting with [yoast-doc-extension], added test instructions for Yoast SEO for Google Docs and attached the Google Docs Add-on label to this PR.

Documentation

  • I have written documentation for this change. For example, comments in the Relevant technical choices, comments in the code, documentation on Confluence / shared Google Drive / Yoast developer portal, or other.

The window.yoast.bulkEditor bridge contract is documented where it is exposed in bulk-editor/initialize.js, including that it is unversioned and that Free and Premium ship in lockstep.

Quality assurance

  • I have tested this code to the best of my abilities.
  • During testing, I had activated all plugins that Yoast SEO provides integrations for.
  • I have added unit tests to verify the code works as intended.
  • If any part of the code is behind a feature flag, my test instructions also cover cases where the feature flag is switched off.
  • I have written this PR in accordance with my team's definition of done.
  • I have checked that the base branch is correctly set.
  • I have run grunt build:images and committed the results, if my PR introduces or edits images or SVGs.

Not behind a feature flag; no images or SVGs touched. Verified in a real install:

  • The bulk editor's prompt content is byte-identical to the in-editor generator's for the same post (matching SHA-256).
  • The route, the bridge (helpers, constants) and the localised shortcode list are all present and correct on the page, and the existing components/hooks entries still are.
  • The per-language researcher is picked up from the page as intended: with the site in Japanese the loaded researcher exposes the custom tokenizer and Japanese content is cut on ; with the site in Indonesian, Buku-buku itu tokenizes as ["Buku-buku", " ", "itu"].
  • Regressions in group D re-checked: the in-editor generator, the in-editor "not enough content" tip, and bulk editor field re-scoring all behave as before.

Innovation

  • No innovation project is applicable for this PR.
  • This PR falls under an innovation project. I have attached the innovation label.
  • I have added my hours to the WBSO document.

Part of Yoast/reserved-tasks#1376 (closed by Yoast/wordpress-seo-premium#5044).

FAMarfuaty and others added 6 commits July 30, 2026 09:54
…dmin

Lets the bulk editor reuse the loop and the 300/150 token budgets.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…service

So field re-scoring and prompt content collection share one instance.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Lets the parser strip shortcode delimiters but keep the text they enclose,
matching the post editor.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Parses a post's raw content on the main thread via ensureTree, so the bulk
editor collects the same prompt content as the in-editor generator.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Adds helpers and constants to the existing window.yoast.bulkEditor bridge, so
Premium needs no yoastseo import and the token budgets stay defined once.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Kept out of the posts route so the table payload stays lean. Inaccessible IDs
are omitted rather than failing the batch.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@coveralls-official

coveralls-official Bot commented Jul 30, 2026

Copy link
Copy Markdown

Coverage Report for CI Build 0

Coverage increased (+0.02%) to 55.677%

Details

  • Coverage increased (+0.02%) from the base build.
  • Patch coverage: 16 uncovered changes across 4 files (80 of 96 lines covered, 83.33%).
  • No coverage regressions found.

Uncovered Changes

File Changed Covered %
src/bulk-editor/infrastructure/endpoints/posts-content-endpoint.php 8 0 0.0%
src/bulk-editor/user-interface/bulk-editor-integration.php 5 1 20.0%
packages/js/src/bulk-editor/services/researcher.js 14 12 85.71%
src/bulk-editor/user-interface/posts-content-route.php 41 39 95.12%
Total (8 files) 96 80 83.33%

Coverage Regressions

No coverage regressions found.


Coverage Stats

Coverage Status
Relevant Lines: 73048
Covered Lines: 40537
Line Coverage: 55.49%
Relevant Branches: 18902
Covered Branches: 10658
Branch Coverage: 56.39%
Branches in Coverage %: Yes
Coverage Strength: 41200.55 hits per line

💛 - Coveralls

FAMarfuaty and others added 3 commits July 30, 2026 11:57
The same helper the in-editor AI tip measures with, so Premium's bulk AI judges
"limited content" identically instead of measuring server-side.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@FAMarfuaty FAMarfuaty added changelog: non-user-facing Needs to be included in the 'Non-userfacing' category in the changelog innovation Innovative issue. Relating to performance, memory or data-flow. labels Jul 30, 2026
@FAMarfuaty
FAMarfuaty marked this pull request as ready for review July 30, 2026 13:37
@FAMarfuaty FAMarfuaty added this to the feature/bulk-editor-ph2 milestone Jul 30, 2026
@FAMarfuaty
FAMarfuaty requested a review from Copilot July 30, 2026 15:06

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR aligns Premium’s bulk-editor AI prompt-content generation with the in-editor AI generator by moving token-budgeted sentence accumulation and visible-content measurement into shared JS helpers, and by exposing bulk-editor-side prompt preparation + constants via the existing window.yoast.bulkEditor bridge. It also introduces a dedicated REST route to fetch raw post_content client-side (plus localized shortcode tags) so the browser can parse content using the analysis engine rather than a server-side mirror.

Changes:

  • Add a new Bulk Editor REST route (/yoast/v1/bulk_editor/posts_content) to fetch raw post content in batches, omitting inaccessible IDs.
  • Introduce shared-admin JS helpers for prompt-content accumulation and visible-content length, and wire them into both the in-editor AI generator and the bulk editor.
  • Expose preparePromptContent, getVisibleContentLength, and token-budget constants on window.yoast.bulkEditor for Premium consumption, plus localize shortcode tags to the bulk editor page.

Reviewed changes

Copilot reviewed 22 out of 23 changed files in this pull request and generated 6 comments.

Show a summary per file
File Description
tests/Unit/Bulk_Editor/User_Interface/Posts_Content_Route/Register_Routes_Test.php Adds unit coverage for REST route registration arguments.
tests/Unit/Bulk_Editor/User_Interface/Posts_Content_Route/Get_Posts_Content_Test.php Adds unit coverage for content retrieval behavior (raw content, omissions, dedupe, casting).
tests/Unit/Bulk_Editor/User_Interface/Posts_Content_Route/Check_Permissions_Test.php Adds unit coverage for route permission callback.
tests/Unit/Bulk_Editor/User_Interface/Posts_Content_Route/Abstract_Posts_Content_Route_Test.php Introduces shared test setup for the new route unit tests.
tests/Unit/Bulk_Editor/User_Interface/Bulk_Editor_Integration/Enqueue_Assets_Test.php Updates localized bulk editor script data expectations to include shortcode tags.
src/bulk-editor/user-interface/posts-content-route.php Implements the new REST route returning raw post_content for requested IDs.
src/bulk-editor/user-interface/bulk-editor-integration.php Localizes registered shortcode tags into bulk editor analysis config.
src/bulk-editor/infrastructure/endpoints/posts-content-endpoint.php Adds an endpoint descriptor for the new posts-content route (for localized endpoints list).
packages/js/tests/shared-admin/helpers/prompt-content.test.js Adds tests for shared prompt-content accumulation logic and budgets.
packages/js/tests/shared-admin/helpers/get-visible-content-length.test.js Updates tests to the new shared-admin getVisibleContentLength location.
packages/js/tests/bulk-editor/services/prompt-content.test.js Adds tests for bulk-editor prompt-content preparation via main-thread parsing.
packages/js/tests/bulk-editor/initialize.test.js Ensures the bulk-editor bridge exposes helpers/constants to Premium without clobbering existing entries.
packages/js/src/shared-admin/helpers/prompt-content.js Introduces shared token-budgeted prompt-content accumulator and constants.
packages/js/src/shared-admin/helpers/index.js Re-exports new shared helpers/constants from the shared-admin barrel.
packages/js/src/shared-admin/helpers/get-visible-content-length.js Moves/introduces visible-content length measurement using analysis sanitization.
packages/js/src/bulk-editor/services/researcher.js Extracts shared researcher singleton construction for bulk-editor main-thread analysis use.
packages/js/src/bulk-editor/services/prompt-content.js Implements main-thread prompt-content preparation using ensureTree() + shared accumulator.
packages/js/src/bulk-editor/services/field-scores.js Refactors field scoring to reuse the extracted researcher singleton.
packages/js/src/bulk-editor/initialize.js Extends the Premium bridge with prompt-content helpers and token budgets.
packages/js/src/ai-generator/helpers/prepare-prompt-content.js Switches in-editor accumulation to the shared prompt-content helper (worker still supplies paragraphs).
packages/js/src/ai-generator/helpers/index.js Stops exporting getVisibleContentLength from the AI generator helpers barrel (moved).
packages/js/src/ai-generator/constants/index.js Removes duplicated token-budget constants in favor of the shared helper.
packages/js/src/ai-generator/components/tip-notification.js Updates visible-content length import to the shared-admin helper module.

* @return WP_REST_Response The posts and their raw content.
*/
public function get_posts_content( WP_REST_Request $request ): WP_REST_Response {
$post_ids = \array_unique( \array_map( '\intval', (array) $request->get_param( 'ids' ) ) );
continue;
}

$post = \get_post( $post_id );
Comment on lines +43 to +65
export const collectPromptContent = ( paragraphs, maxTokens ) => {
let promptContent = "";
let tokenCount = 0;

( paragraphs || [] ).forEach( ( paragraph ) => {
( paragraph.sentences || [] ).forEach( ( sentence ) => {
tokenCount += sentence.tokens.length;
// Stop when the sentences so far exceed the maximum allowed number of tokens.
if ( tokenCount > maxTokens ) {
return;
}

promptContent += sanitizeText( sentence.text );
} );

// Add a space between paragraphs, which counts as a token itself.
promptContent += " ";
tokenCount += 1;
} );

// To prevent a completely empty prompt content, fall back to a single full stop.
return promptContent.trimEnd() || ".";
};
Comment on lines 5 to +7
export { getModalNotificationPosition } from "./get-modal-notification-position";
export { collectPromptContent, MAX_TOKENS_DEFAULT, MAX_TOKENS_IRREGULAR } from "./prompt-content";
export { getVisibleContentLength } from "./get-visible-content-length";
Comment on lines +31 to +35
// The registered shortcode tags are read straight off the WordPress global.
$GLOBALS['shortcode_tags'] = [
'gallery' => 'gallery_shortcode',
'caption' => 'caption_shortcode',
];
Comment on lines +11 to +20
class Posts_Content_Endpoint implements Bulk_Editor_Endpoint_Interface {

/**
* Gets the name.
*
* @return string
*/
public function get_name(): string {
return 'posts_content';
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

changelog: non-user-facing Needs to be included in the 'Non-userfacing' category in the changelog innovation Innovative issue. Relating to performance, memory or data-flow.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants