Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
a74a94e
feat(contract): define serializable PaperDTO schema and conversion fu…
FAMarfuaty Jun 5, 2026
be722fa
feat(contract): expose PaperDTO via the yoastseo/contract entry
FAMarfuaty Jun 5, 2026
76b314f
feat(contract): enhance serializable input contract with additional m…
FAMarfuaty Jun 5, 2026
2df246c
feat(analyze): refactor to use paperFromRequest helper for input vali…
FAMarfuaty Jun 5, 2026
dc05c91
feat(contract): add deprecated `keyword` alias for `keyphrase` in Pap…
FAMarfuaty Jun 6, 2026
b7d09cf
feat(contract): update PaperDTO to include open-ended customData fiel…
FAMarfuaty Jun 8, 2026
79a8c00
feat(contract): add createToPaper function for consumer-defined input…
FAMarfuaty Jun 8, 2026
d936fa5
feat(contract): document PaperDTO as a serializable input contract fo…
FAMarfuaty Jun 8, 2026
6115c62
feat(contract): update PaperDTO to include optional, deprecated WordP…
FAMarfuaty Jun 8, 2026
9031a1d
Update doc
FAMarfuaty Jun 9, 2026
6038253
Merge branch 'trunk' of github.com:Yoast/wordpress-seo into 634-api-m…
FAMarfuaty Jun 9, 2026
3bfdf41
refactor(paper): replace Paper instantiation with toPaper contract an…
FAMarfuaty Jun 9, 2026
3b42d51
feat(analyze): enforce keyphrase requirement for keyphrase analysis e…
FAMarfuaty Jun 9, 2026
594d6c2
refactor(contract): drop lodash for the keyphrase alias, document str…
FAMarfuaty Jun 9, 2026
7906016
docs(contract): fix isFrontPage describe text (unmatched paren)
FAMarfuaty Jun 9, 2026
5bbc129
refactor(contract): update customData handling and improve documentat…
FAMarfuaty Jun 15, 2026
deca983
refactor(analyze): replace locale handling with paperLanguage functio…
FAMarfuaty Jun 24, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 22 additions & 0 deletions apps/content-analysis-api/helpers/paper-from-request.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
const { toPaper } = require( "yoastseo/contract" );

/**
* Builds a Paper from the request body via the PaperDTO contract (`yoastseo/contract`).
*
* On a structurally invalid body (wrong types, unknown keys, missing `text`) it responds with a 400 and
* returns null, so callers should bail when the result is falsy.
*
* @param {Object} request The Express request.
* @param {Object} response The Express response.
* @returns {Object|null} The constructed Paper, or null when the body was rejected.
*/
const paperFromRequest = ( request, response ) => {
try {
return toPaper( request.body || {} );
} catch ( error ) {
response.status( 400 ).json( { error: "Invalid request body", details: error.issues || String( error ) } );
return null;
}
};

module.exports = { paperFromRequest };
13 changes: 13 additions & 0 deletions apps/content-analysis-api/helpers/paper-language.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
/**
* Derives the language code from a Paper's validated locale.
*
* The contract validates and normalises `locale` onto the Paper (defaulting to `en_US`), so the
* researcher language is taken from there rather than from the raw request body — keeping the routes
* consistent with the values that passed through the contract.
*
* @param {Object} paper The Paper constructed via the contract.
* @returns {string} The language code (the locale's prefix, e.g. `en` from `en_US`).
*/
const paperLanguage = ( paper ) => paper.getLocale().split( /[-_]/ )[ 0 ];

module.exports = { paperLanguage };
116 changes: 62 additions & 54 deletions apps/content-analysis-api/routes/analyze.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
const { Paper, assessments, assessors, interpreters } = require( "yoastseo" );
const { assessments, assessors, interpreters } = require( "yoastseo" );
const { getResearcher } = require( "../helpers/get-researcher" );
const { paperFromRequest } = require( "../helpers/paper-from-request" );
const { paperLanguage } = require( "../helpers/paper-language" );

const express = require( "express" ), app = express();

Expand Down Expand Up @@ -32,9 +34,13 @@ const resultToVM = ( result ) => {

module.exports = function( app ) {
app.get( "/analyze", ( request, response ) => {
// Fetch the Researcher and set the morphology data for the given language (yes, this is a bit hacky)
const language = request.body.locale || "en";
const paper = paperFromRequest( request, response );
if ( ! paper ) {
return;
}

// Fetch the Researcher and set the morphology data for the given language (yes, this is a bit hacky)
const language = paperLanguage( paper );
const researcher = getResearcher( language );

const seoAssessor = new SEOAssessor( researcher );
Expand All @@ -46,11 +52,6 @@ module.exports = function( app ) {
const relatedKeywordAssessor = new RelatedKeywordAssessor( researcher );
const inclusiveLanguageAssessor = new InclusiveLanguageAssessor( researcher );

const paper = new Paper(
request.body.text || "",
request.body || {}
);

seoAssessor.assess( paper );
contentAssessor.assess( paper );
relatedKeywordAssessor.assess( paper );
Expand All @@ -65,116 +66,123 @@ module.exports = function( app ) {
} );

app.get( "/analyze/seo", ( request, response ) => {
const language = request.body.locale || "en";
const paper = paperFromRequest( request, response );
if ( ! paper ) {
return;
}
const language = paperLanguage( paper );
const researcher = getResearcher( language );
const assessor = new SEOAssessor( researcher );
assessor.addAssessment( "keyphraseDistribution", new KeyphraseDistributionAssessment() );
assessor.addAssessment( "TextTitleAssessment", new TextTitleAssessment() );

const paper = new Paper(
request.body.text || "",
request.body || {}
);
assessor.assess( paper );
response.json( assessor.getValidResults().map( resultToVM ) );
} );

app.get( "/analyze/readability", ( request, response ) => {
const language = request.body.locale || "en";
const paper = paperFromRequest( request, response );
if ( ! paper ) {
return;
}
const language = paperLanguage( paper );
const researcher = getResearcher( language );
const assessor = new ContentAssessor( researcher );
assessor.addAssessment( "wordComplexity", new WordComplexityAssessment() );
assessor.addAssessment( "textAlignment", new TextAlignmentAssessment() );
const paper = new Paper(
request.body.text || "",
request.body || {}
);

assessor.assess( paper );
response.json( assessor.getValidResults().map( resultToVM ) );
} );

app.get( "/analyze/related-keyphrase", ( request, response ) => {
const language = request.body.locale || "en";
const paper = paperFromRequest( request, response );
if ( ! paper ) {
return;
}
const language = paperLanguage( paper );
const researcher = getResearcher( language );
const assessor = new RelatedKeywordAssessor( researcher );
const paper = new Paper(
request.body.text || "",
request.body || {}
);

assessor.assess( paper );
response.json( assessor.getValidResults().map( resultToVM ) );
} );

app.get( "/analyze/inclusive-language", ( request, response ) => {
const language = request.body.locale || "en";
const paper = paperFromRequest( request, response );
if ( ! paper ) {
return;
}
const language = paperLanguage( paper );
const researcher = getResearcher( language );
const assessor = new InclusiveLanguageAssessor( researcher );
const paper = new Paper(
request.body.text || "",
request.body || {}
);

assessor.assess( paper );
response.json( assessor.getValidResults().map( resultToVM ) );
} );

app.get( "/analyze/meta-description", ( request, response ) => {
if (! request.body.description) {
const paper = paperFromRequest( request, response );
if ( ! paper ) {
return;
}
// This endpoint analyses the meta description, so one is required (the contract leaves it optional).
if ( ! paper.getDescription() ) {
return response.status( 400 ).json( { error: "Description is required" } );
}
const language = request.body.locale || "en";
const language = paperLanguage( paper );
const researcher = getResearcher( language );
const assessor = new MetaDescriptionAssessor( researcher );
const paper = new Paper(
request.body.text || "",
request.body || {}
);
assessor.assess( paper );
response.json( assessor.getValidResults().map( resultToVM ) );
} );

app.get( "/analyze/seo-title", ( request, response ) => {
if (! request.body.title) {
const paper = paperFromRequest( request, response );
if ( ! paper ) {
return;
}
// This endpoint analyses the SEO title, so one is required (the contract leaves it optional).
if ( ! paper.getTitle() ) {
return response.status( 400 ).json( { error: "Title is required" } );
}
const language = request.body.locale || "en";
const language = paperLanguage( paper );
const researcher = getResearcher( language );
const assessor = new SeoTitleAssessor( researcher );
const paper = new Paper(
request.body.text || "",
request.body || {}
);
assessor.assess( paper );
response.json( assessor.getValidResults().map( resultToVM ) );
} );

app.get( "/analyze/keyphrase", ( request, response ) => {
if (! request.body.keyword) {
return response.status( 400 ).json( { error: "Keyword is required" } );
const paper = paperFromRequest( request, response );
if ( ! paper ) {
return;
}
// This endpoint is keyphrase analysis, so a keyphrase is required (the contract leaves it optional).
if ( ! paper.hasKeyword() ) {
return response.status( 400 ).json( { error: "A keyphrase is required" } );
}
const language = request.body.locale || "en";
const language = paperLanguage( paper );
const researcher = getResearcher( language );
const assessor = new KeyphraseAssessor( researcher );
const paper = new Paper(
request.body.text || "",
request.body || {}
);
assessor.assess( paper );
response.json( assessor.getValidResults().map( resultToVM ) );
} );

app.get( "/analyze/keyphrase-use", ( request, response ) => {
if (! request.body.keyword) {
return response.status( 400 ).json( { error: "Keyword is required" } );
const paper = paperFromRequest( request, response );
if ( ! paper ) {
return;
}
// This endpoint is keyphrase analysis, so a keyphrase is required (the contract leaves it optional).
if ( ! paper.hasKeyword() ) {
return response.status( 400 ).json( { error: "A keyphrase is required" } );
}
const language = request.body.locale || "en";
const language = paperLanguage( paper );
const researcher = getResearcher( language );
const assessor = new KeyphraseUseAssessor( researcher );
assessor.addAssessment( "keyphraseDistribution", new KeyphraseDistributionAssessment() );

const paper = new Paper(
request.body.text || "",
request.body || {}
);
assessor.assess( paper );
response.json( assessor.getValidResults().map( resultToVM ) );
} );
Expand Down
53 changes: 27 additions & 26 deletions apps/content-analysis-api/routes/research.js
Original file line number Diff line number Diff line change
@@ -1,52 +1,53 @@
const { Paper } = require( "yoastseo" );
const { build } = require( "yoastseo/build/parse/build" );
const { LanguageProcessor } = require( "yoastseo/build/parse/language" );
const { getResearcher } = require( "../helpers/get-researcher" );
const { paperFromRequest } = require( "../helpers/paper-from-request" );
const { paperLanguage } = require( "../helpers/paper-language" );

module.exports = function( app ) {
app.get( "/research/estimated-reading-time", ( request, response ) => {
const language = request.body.locale || "en";
const paper = paperFromRequest( request, response );
if ( ! paper ) {
return;
}
const language = paperLanguage( paper );
const researcher = getResearcher( language );
const paper = new Paper(
request.body.text || "",
request.body || {}
);
researcher.setPaper( paper );
const estimatedReadingTime = researcher.getResearch( "readingTime" );
response.json( { time: estimatedReadingTime } );
} );

app.get( "/research/flesch-reading-ease", ( request, response ) => {
const language = request.body.locale || "en";
const paper = paperFromRequest( request, response );
if ( ! paper ) {
return;
}
const language = paperLanguage( paper );
const researcher = getResearcher( language );
const paper = new Paper(
request.body.text || "",
request.body || {}
);
researcher.setPaper( paper );
const fleschReadingEaseScore = researcher.getResearch( "getFleschReadingScore" );
response.json( { score: fleschReadingEaseScore.score, difficulty: fleschReadingEaseScore.difficulty } );
} );

app.get( "/research/word-count", ( request, response ) => {
const language = request.body.locale || "en";
const paper = paperFromRequest( request, response );
if ( ! paper ) {
return;
}
const language = paperLanguage( paper );
const researcher = getResearcher( language );
const paper = new Paper(
request.body.text || "",
request.body || {}
);
researcher.setPaper( paper );
const wordCount = researcher.getResearch( "wordCountInText" );
response.json( { count: wordCount.count, unit: wordCount.unit } );
} );

app.get( "/research/sentence-count", ( request, response ) => {
const language = request.body.locale || "en";
const paper = paperFromRequest( request, response );
if ( ! paper ) {
return;
}
const language = paperLanguage( paper );
const researcher = getResearcher( language );
const paper = new Paper(
request.body.text || "",
request.body || {}
);
paper.setTree( build( paper, new LanguageProcessor( researcher ), paper._attributes && paper._attributes.shortcodes ) );
researcher.setPaper( paper );
const sentenceLengths = researcher.getResearch( "countSentencesFromText" );
Expand All @@ -56,12 +57,12 @@ module.exports = function( app ) {
} );

app.get( "/research/paragraph-count", ( request, response ) => {
const language = request.body.locale || "en";
const paper = paperFromRequest( request, response );
if ( ! paper ) {
return;
}
const language = paperLanguage( paper );
const researcher = getResearcher( language );
const paper = new Paper(
request.body.text || "",
request.body || {}
);
paper.setTree( build( paper, new LanguageProcessor( researcher ), paper._attributes && paper._attributes.shortcodes ) );
researcher.setPaper( paper );
const paragraphLengths = researcher.getResearch( "getParagraphLength" );
Expand Down
8 changes: 4 additions & 4 deletions apps/content-analysis-webworker/src/index.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { AnalysisWorkerWrapper, Paper, interpreters } from "yoastseo";
import { AnalysisWorkerWrapper, interpreters } from "yoastseo";
import { toPaper } from "yoastseo/contract";

const loadWebWorker = ( language ) => {
const workerUnwrapped = new Worker( new URL("./worker.js", import.meta.url) );
Expand Down Expand Up @@ -40,9 +41,8 @@ document.addEventListener("DOMContentLoaded", function(event) {
logLevel: "TRACE", // Optional, see https://github.com/pimterry/loglevel#documentation
} ).then( () => {
// The worker has been configured, we can now analyze a Paper.
const paper = new Paper( paperText, {
keyword: keyphrase,
} );
// Build the Paper through the serializable input contract (`yoastseo/contract`).
const paper = toPaper( { text: paperText, keyphrase } );

return worker.analyze( paper );
} ).then( ( results ) => {
Expand Down
Loading
Loading