Skip to content
Open
Show file tree
Hide file tree
Changes from 16 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 };
95 changes: 50 additions & 45 deletions apps/content-analysis-api/routes/analyze.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
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 express = require( "express" ), app = express();

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

module.exports = function( app ) {
app.get( "/analyze", ( request, response ) => {
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 = request.body.locale || "en";

const researcher = getResearcher( language );

const seoAssessor = new SEOAssessor( researcher );
Expand All @@ -46,11 +51,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 +65,121 @@ module.exports = function( app ) {
} );

app.get( "/analyze/seo", ( request, response ) => {
const paper = paperFromRequest( request, response );
if ( ! paper ) {
return;
}
const language = request.body.locale || "en";
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 paper = paperFromRequest( request, response );
if ( ! paper ) {
return;
}
const language = request.body.locale || "en";
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 paper = paperFromRequest( request, response );
if ( ! paper ) {
return;
}
const language = request.body.locale || "en";
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 paper = paperFromRequest( request, response );
if ( ! paper ) {
return;
}
const language = request.body.locale || "en";
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) {
if ( ! request.body.description ) {
return response.status( 400 ).json( { error: "Description is required" } );
}
const paper = paperFromRequest( request, response );
if ( ! paper ) {
return;
}
const language = request.body.locale || "en";
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) {
if ( ! request.body.title ) {
return response.status( 400 ).json( { error: "Title is required" } );
}
const paper = paperFromRequest( request, response );
if ( ! paper ) {
return;
}
const language = request.body.locale || "en";
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 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 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
42 changes: 21 additions & 21 deletions apps/content-analysis-api/routes/research.js
Original file line number Diff line number Diff line change
@@ -1,52 +1,52 @@
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" );

module.exports = function( app ) {
app.get( "/research/estimated-reading-time", ( request, response ) => {
const paper = paperFromRequest( request, response );
if ( ! paper ) {
return;
}
const language = request.body.locale || "en";
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 paper = paperFromRequest( request, response );
if ( ! paper ) {
return;
}
const language = request.body.locale || "en";
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 paper = paperFromRequest( request, response );
if ( ! paper ) {
return;
}
const language = request.body.locale || "en";
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 paper = paperFromRequest( request, response );
if ( ! paper ) {
return;
}
const language = request.body.locale || "en";
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 +56,12 @@ module.exports = function( app ) {
} );

app.get( "/research/paragraph-count", ( request, response ) => {
const paper = paperFromRequest( request, response );
if ( ! paper ) {
return;
}
const language = request.body.locale || "en";
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
26 changes: 24 additions & 2 deletions packages/yoastseo/GLOSSARY.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,28 @@ const paper = new Paper("<p>This is the <strong>main</strong> content<p>", {
});
```

### <a name="paperdto"></a>PaperDTO
A documented, serializable **input contract** for the engine (neutral core + a few optional, deprecated WordPress-transitional fields), exposed via the opt-in `yoastseo/contract` entry. A non-WordPress consumer sends a `PaperDTO` (plain JSON) and the `toPaper` boundary validates it and constructs an internal [Paper](#paper). It is the *external* counterpart of `Paper`: where `Paper` is the engine's internal value object, `PaperDTO` is the stable shape consumers send.

Key differences from `Paper`:
- Uses the canonical name **`keyphrase`** (mapped to the engine's `keyword`); `keyword` is accepted as a deprecated alias.
- Carries the WordPress-transitional fields (`wpBlocks`, `shortcodes`, `isFrontPage`) as **optional, deprecated** — they are real analysis inputs that change WordPress scores, so a remote/API analysis needs them for result parity.
- Authored in [zod](https://zod.dev); validates structure (wrong types / unknown keys throw) while leaving per-assessment fields optional (omitting one just skips that assessment).
- Consumers that register their own analysis (e.g., assessments) pass those inputs through the opaque `customData` object, whose contents are not validated.

**Example:**
```javascript
import { toPaper } from "yoastseo/contract";

const paper = toPaper({
text: "<p>This is the <strong>main</strong> content</p>",
keyphrase: "example",
description: "This is a meta description",
slug: "example-page",
locale: "en_US"
});
```

### <a name="assessment"></a>Assessment
A single analysis unit that evaluates one specific aspect of content. Each assessment:
- Has a specific purpose (e.g., the _keyword density_ assessment evaluates the number of keywords used in the content)
Expand Down Expand Up @@ -59,7 +81,7 @@ Types of assessors include:
- ReadabilityAssessor: Analyzes text readability
- CornerStoneAssessor: Applies stricter rules for important content

The diagram below shows an example hierarchy of assessors and assessments.
The diagram below shows an example hierarchy of assessors and assessments.

```mermaid
graph TD
Expand Down Expand Up @@ -179,4 +201,4 @@ Alternative words or phrases with similar meaning to the keyphrase. Used to:
```
Keyphrase: "car"
Synonyms: "automobile", "vehicle", "motor vehicle"
```
```
Loading
Loading