diff --git a/apps/content-analysis-api/helpers/paper-from-request.js b/apps/content-analysis-api/helpers/paper-from-request.js index da3da5057a4..1e9164ec480 100644 --- a/apps/content-analysis-api/helpers/paper-from-request.js +++ b/apps/content-analysis-api/helpers/paper-from-request.js @@ -1,7 +1,7 @@ const { toPaper } = require( "yoastseo/contract" ); /** - * Builds a Paper from the request body via the PaperDTO contract (`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. diff --git a/apps/content-analysis-api/routes/analyze.js b/apps/content-analysis-api/routes/analyze.js index 211ceea0854..ff62bcd525f 100644 --- a/apps/content-analysis-api/routes/analyze.js +++ b/apps/content-analysis-api/routes/analyze.js @@ -1,4 +1,5 @@ -const { assessments, assessors, interpreters } = require( "yoastseo" ); +const { assessments, assessors } = require( "yoastseo" ); +const { toResultDto } = require( "yoastseo/contract" ); const { getResearcher } = require( "../helpers/get-researcher" ); const { paperFromRequest } = require( "../helpers/paper-from-request" ); const { paperLanguage } = require( "../helpers/paper-language" ); @@ -22,16 +23,6 @@ const TextTitleAssessment = assessments.seo.TextTitleAssessment; const WordComplexityAssessment = assessments.readability.WordComplexityAssessment; const TextAlignmentAssessment = assessments.readability.TextAlignmentAssessment; -/** - * Maps the result to a view model, ready to be sent to the client. - * @param {AssessmentResult[]} result The result to map. - * @returns {{score, editFieldName, text, marks}} The view model. - */ -const resultToVM = ( result ) => { - const { _identifier, score, text, marks, editFieldName } = result; - return { _identifier, score, text, marks, editFieldName, rating: interpreters.scoreToRating( score ) }; -}; - module.exports = function( app ) { app.get( "/analyze", ( request, response ) => { const paper = paperFromRequest( request, response ); @@ -58,10 +49,10 @@ module.exports = function( app ) { inclusiveLanguageAssessor.assess( paper ); response.json( { - seo: seoAssessor.getValidResults().map( resultToVM ), - readability: contentAssessor.getValidResults().map( resultToVM ), - relatedKeyword: relatedKeywordAssessor.getValidResults().map( resultToVM ), - inclusiveLanguage: inclusiveLanguageAssessor.getValidResults().map( resultToVM ), + seo: seoAssessor.getValidResults().map( toResultDto ), + readability: contentAssessor.getValidResults().map( toResultDto ), + relatedKeyword: relatedKeywordAssessor.getValidResults().map( toResultDto ), + inclusiveLanguage: inclusiveLanguageAssessor.getValidResults().map( toResultDto ), } ); } ); @@ -77,7 +68,7 @@ module.exports = function( app ) { assessor.addAssessment( "TextTitleAssessment", new TextTitleAssessment() ); assessor.assess( paper ); - response.json( assessor.getValidResults().map( resultToVM ) ); + response.json( assessor.getValidResults().map( toResultDto ) ); } ); app.get( "/analyze/readability", ( request, response ) => { @@ -92,7 +83,7 @@ module.exports = function( app ) { assessor.addAssessment( "textAlignment", new TextAlignmentAssessment() ); assessor.assess( paper ); - response.json( assessor.getValidResults().map( resultToVM ) ); + response.json( assessor.getValidResults().map( toResultDto ) ); } ); app.get( "/analyze/related-keyphrase", ( request, response ) => { @@ -105,7 +96,7 @@ module.exports = function( app ) { const assessor = new RelatedKeywordAssessor( researcher ); assessor.assess( paper ); - response.json( assessor.getValidResults().map( resultToVM ) ); + response.json( assessor.getValidResults().map( toResultDto ) ); } ); app.get( "/analyze/inclusive-language", ( request, response ) => { @@ -118,7 +109,7 @@ module.exports = function( app ) { const assessor = new InclusiveLanguageAssessor( researcher ); assessor.assess( paper ); - response.json( assessor.getValidResults().map( resultToVM ) ); + response.json( assessor.getValidResults().map( toResultDto ) ); } ); app.get( "/analyze/meta-description", ( request, response ) => { @@ -134,7 +125,7 @@ module.exports = function( app ) { const researcher = getResearcher( language ); const assessor = new MetaDescriptionAssessor( researcher ); assessor.assess( paper ); - response.json( assessor.getValidResults().map( resultToVM ) ); + response.json( assessor.getValidResults().map( toResultDto ) ); } ); app.get( "/analyze/seo-title", ( request, response ) => { @@ -150,7 +141,7 @@ module.exports = function( app ) { const researcher = getResearcher( language ); const assessor = new SeoTitleAssessor( researcher ); assessor.assess( paper ); - response.json( assessor.getValidResults().map( resultToVM ) ); + response.json( assessor.getValidResults().map( toResultDto ) ); } ); app.get( "/analyze/keyphrase", ( request, response ) => { @@ -166,7 +157,7 @@ module.exports = function( app ) { const researcher = getResearcher( language ); const assessor = new KeyphraseAssessor( researcher ); assessor.assess( paper ); - response.json( assessor.getValidResults().map( resultToVM ) ); + response.json( assessor.getValidResults().map( toResultDto ) ); } ); app.get( "/analyze/keyphrase-use", ( request, response ) => { @@ -184,6 +175,6 @@ module.exports = function( app ) { assessor.addAssessment( "keyphraseDistribution", new KeyphraseDistributionAssessment() ); assessor.assess( paper ); - response.json( assessor.getValidResults().map( resultToVM ) ); + response.json( assessor.getValidResults().map( toResultDto ) ); } ); } diff --git a/packages/js/src/components/contentAnalysis/mapResults.js b/packages/js/src/components/contentAnalysis/mapResults.js index 6409c3b7263..40fdae0ca60 100644 --- a/packages/js/src/components/contentAnalysis/mapResults.js +++ b/packages/js/src/components/contentAnalysis/mapResults.js @@ -45,9 +45,9 @@ function mapResult( result, key = "" ) { id, text: result.text, markerId: key.length > 0 ? `${key}:${id}` : id, - hasBetaBadge: result.hasBetaBadge(), + hasBetaBadge: result.isBeta(), hasJumps: result.hasJumps(), - hasAIFixes: result.hasAIFixes(), + hasAIFixes: result.isOptimizable(), editFieldName: result.editFieldName, editFieldAriaLabel: result.editFieldAriaLabel, }; diff --git a/packages/yoastseo/README.md b/packages/yoastseo/README.md index 3b740addcf3..74278f59c0a 100644 --- a/packages/yoastseo/README.md +++ b/packages/yoastseo/README.md @@ -7,18 +7,19 @@ This library can generate metrics about a text and assess these metrics to give  ## Documentation -* A high-level [architecture overview](https://github.com/Yoast/wordpress-seo/blob/trunk/packages/yoastseo/OVERVIEW.md) of the package. -* A [glossary](https://github.com/Yoast/wordpress-seo/blob/trunk/packages/yoastseo/GLOSSARY.md) of the core domain concepts (Paper, Assessor, Researcher, etc.). -* A list of all the [assessors](https://github.com/Yoast/wordpress-seo/blob/trunk/packages/yoastseo/src/scoring/assessors/ASSESSORS%20OVERVIEW.md) -* Information on the [scoring system of the assessments](https://github.com/Yoast/wordpress-seo/blob/trunk/packages/yoastseo/src/scoring/assessments/README.md) - * [SEO analysis scoring](https://github.com/Yoast/wordpress-seo/blob/trunk/packages/yoastseo/src/scoring/assessments/SCORING%20SEO.md) - * [Readability analysis scoring](https://github.com/Yoast/wordpress-seo/blob/trunk/packages/yoastseo/src/scoring/assessments/SCORING%20READABILITY.md) - * [Inclusive language analysis scoring](https://github.com/Yoast/wordpress-seo/blob/trunk/packages/yoastseo/src/scoring/assessments/SCORING%20INCLUSIVE%20LANGUAGE.md) - * [How keyphrase matching works](https://github.com/Yoast/wordpress-seo/blob/trunk/packages/yoastseo/src/scoring/assessments/KEYPHRASE%20MATCHING.md) - * [Scoring on taxonomy pages](https://github.com/Yoast/wordpress-seo/blob/trunk/packages/yoastseo/src/scoring/assessments/SCORING%20TAXONOMY.md) -* The data that will be analyzed by YoastSEO.js can be modified by plugins. Plugins can also add new research and assessments. To find out how to do this, checkout out the [customization documentation](https://github.com/Yoast/wordpress-seo/blob/trunk/packages/yoastseo/docs/Customization.md). -* Information on the design decisions within the package can be found [here](https://github.com/Yoast/wordpress-seo/blob/trunk/packages/yoastseo/DESIGN%20DECISIONS.md). -* Information on how morphology works in `yoastseo` package can be found [here](https://github.com/Yoast/wordpress-seo/blob/trunk/packages/yoastseo/MORPHOLOGY.md). +* A high-level [architecture overview](docs/OVERVIEW.md) of the package. +* A [glossary](docs/GLOSSARY.md) of the core domain concepts (Paper, Assessor, Researcher, etc.). +* A list of all the [assessors](src/scoring/assessors/ASSESSORS%20OVERVIEW.md). +* Information on the [scoring system of the assessments](src/scoring/assessments/README.md) + * [SEO analysis scoring](src/scoring/assessments/SCORING%20SEO.md) + * [Readability analysis scoring](src/scoring/assessments/SCORING%20READABILITY.md) + * [Inclusive language analysis scoring](src/scoring/assessments/SCORING%20INCLUSIVE%20LANGUAGE.md) + * [How keyphrase matching works](src/scoring/assessments/KEYPHRASE%20MATCHING.md) + * [Scoring on taxonomy pages](src/scoring/assessments/SCORING%20TAXONOMY.md) +* The data that will be analyzed by YoastSEO.js can be modified by plugins. Plugins can also add new research and assessments. To find out how to do this, check out the [customization documentation](docs/Customization.md). +* Information on the design decisions within the package can be found [here](docs/DESIGN%20DECISIONS.md). +* Information on how morphology works in `yoastseo` package can be found [here](docs/MORPHOLOGY.md). +* The [serializable contract](docs/CONTRACT.md) (`yoastseo/contract`) that lets non-WordPress consumers exchange a `PaperDto` input and `ResultDto` output with the engine. ## Installation You can install YoastSEO.js using npm: @@ -39,14 +40,15 @@ You can either use YoastSEO.js using the web worker API or use the internal comp ### Entry points -The package exposes two supported public entry points: +The package exposes three supported public entry points: -| Import | Provides | Use it for | -| --- | --- | --- | -| `yoastseo` | The core analysis library: `Paper`, the assessors, the web-worker API (`AnalysisWebWorker`, `AnalysisWorkerWrapper`), `AbstractResearcher`, `helpers`, and related building blocks. | Orchestrating analysis. | -| `yoastseo/researcher` | A `getResearcher( language )` factory that returns the language-specific `Researcher` **class** (falling back to the default, language-agnostic Researcher for unsupported languages). | Resolving a per-language Researcher (Node, custom bundlers, web workers). | +| Import | Provides | Use it for | +|-----------------------|----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|---------------------------------------------------------------------------------------------------------------------------------| +| `yoastseo` | The core analysis library: `Paper`, the assessors, the web-worker API (`AnalysisWebWorker`, `AnalysisWorkerWrapper`), `AbstractResearcher`, `helpers`, and related building blocks. | Orchestrating analysis. | +| `yoastseo/researcher` | A `getResearcher( language )` factory that returns the language-specific `Researcher` **class** (falling back to the default, language-agnostic Researcher for unsupported languages). | Resolving a per-language Researcher (Node, custom bundlers, web workers). | +| `yoastseo/contract` | The serializable input/output contract: `toPaper`/`paperDtoSchema` (and `createToPaper`) and `toResultDto`/`resultDtoSchema`, for validating and mapping analysis input and output. | Exchanging documented, serializable shapes with the engine from non-WordPress consumers. See [`CONTRACT.md`](docs/CONTRACT.md). | -> **Why two entry points?** The split is intentional. Each language `Researcher` transitively pulls in that language's data — function words, stemmers, transition words, and so on. Re-exporting the factory from the package root would bundle *every* language (~2.4 MB) into whatever bundle imports the root. That especially hurts consumers that load `yoastseo` as a bundler **external** — where the package root is provided once as a shared global (or shared chunk) rather than bundled into each consumer. (Yoast SEO for WordPress does this, exposing the root as the `window.yoast.analysis` global, but any webpack/Rollup setup can configure `yoastseo` as an external the same way.) Keeping `getResearcher` on its own entry keeps that shared root lean and lets consumers load only the languages they need. +> **Why separate entry points?** The split is intentional. Each language `Researcher` transitively pulls in that language's data — function words, stemmers, transition words, and so on. Re-exporting the factory from the package root would bundle *every* language (~2.4 MB) into whatever bundle imports the root. That especially hurts consumers that load `yoastseo` as a bundler **external** — where the package root is provided once as a shared global (or shared chunk) rather than bundled into each consumer. (Yoast SEO for WordPress does this, exposing the root as the `window.yoast.analysis` global, but any webpack/Rollup setup can configure `yoastseo` as an external the same way.) Keeping `getResearcher` on its own entry keeps that shared root lean and lets consumers load only the languages they need. The `yoastseo/contract` entry follows the same principle: it isolates the contract's runtime dependency (`zod`) so only consumers that import the contract pay for it, keeping the shared root free of it. > > Deep imports such as `yoastseo/build/...` and `yoastseo/src/...` reach internal modules. They work, but they are implementation details — not part of the supported surface — so prefer the entry points above. @@ -112,7 +114,7 @@ There is also a more involved example [over here](https://github.com/Yoast/wordp ### Usage of internal components -If you want to have a more bare-bones API, or are in an environment without access to Web Worker you can use the internal objects: +If you want to have a more bare-bones API or are in an environment without access to Web Worker, you can use the internal objects: ```js import { AbstractResearcher, Paper } from "yoastseo"; @@ -127,29 +129,6 @@ console.log( researcher.getResearch( "wordCountInText" ) ); There is a basic example of this setup [over here](https://github.com/Yoast/wordpress-seo/tree/trunk/apps/content-analysis-api). -### Serializable input contract (`yoastseo/contract`) - -Non-WordPress consumers (a web API, the Shopify app, the Google Docs extension, …) can send a documented, serializable input shape — a `PaperDTO` — instead of constructing a `Paper` by hand. The contract is a separate, opt-in entry point, so its validation dependency is only loaded by consumers that import it; the package root is unaffected. - -```js -import { toPaper } from "yoastseo/contract"; - -// `toPaper` validates the input and returns an engine `Paper`. -const paper = toPaper( { - text: "Text to analyze", - keyphrase: "analyze", - locale: "en_US", -} ); - -// `paper` can now be passed to `worker.analyze( paper )` or `assessor.assess( paper )`. -``` - -Notes: -- **Covers the analysis inputs.** The neutral core is `text`, `keyphrase`, `synonyms`, `locale`, `description`, `title`, `slug`, `permalink`, `titleWidth`, `textTitle`, `date`, `writingDirection`, and an opaque `customData` object. The contract also carries optional, **deprecated** WordPress-transitional fields (`wpBlocks`, `shortcodes`, `isFrontPage`): they are real analysis inputs that change WordPress scores, so a remote/API analysis needs them to reproduce in-browser results. They are marked deprecated. Non-WordPress consumers simply omit them. -- **`keyphrase` is the canonical field name.** `keyword` is accepted as a deprecated alias, so existing consumers can adopt the contract without renaming. -- **Validation.** `toPaper` throws on structurally invalid input (wrong types, unknown keys). Omitting an optional field is fine — the assessments that need it are simply skipped, matching the engine's existing behaviour. -- **Custom analysis.** A consumer that registers its own analysis (e.g., assessments, researcher) can pass their inputs through the opaque `customData` object. Its contents are not validated, so the consumer's own analysis is responsible for reading and validating them. - ## Supported languages ### SEO analysis @@ -205,7 +184,7 @@ The inclusive language analysis is currently available in English. ## Change log -Please see [CHANGELOG](CHANGELOG.md) for more information what has changed recently. +Please see [CHANGELOG](CHANGELOG.md) for more information on what has changed recently. ## Testing @@ -271,7 +250,7 @@ Please see [CONTRIBUTING](.github/CONTRIBUTING.md) for details. ## Security -If you discover any security related issues, please email security [at] yoast.com instead of using the issue tracker. +If you discover any security-related issues, please email security [at] yoast.com instead of using the issue tracker. ## Credits @@ -280,4 +259,4 @@ If you discover any security related issues, please email security [at] yoast.co ## License -We follow the GPL. Please see [License](LICENSE) file for more information. +We follow the GPL. Please see [the License](LICENSE) file for more information. diff --git a/packages/yoastseo/docs/CONTRACT.md b/packages/yoastseo/docs/CONTRACT.md new file mode 100644 index 00000000000..e829eafa8e4 --- /dev/null +++ b/packages/yoastseo/docs/CONTRACT.md @@ -0,0 +1,55 @@ +# Serializable contract (`yoastseo/contract`) + +Non-WordPress consumers (a hosted web API, the Shopify app, the Google Docs extension, …) can exchange documented, serializable shapes with the analysis engine instead of constructing `Paper` objects and hand-rolling result view models. The contract is a separate, **opt-in** entry point (`yoastseo/contract`), so its validation dependency (`zod`) is only loaded by consumers that import it — the package root, and therefore the WordPress bundle, is unaffected. + +It has two halves: an **input** contract (`PaperDto` → `toPaper`) and an **output** contract (`ResultDto` → `toResultDto`). See also the [`PaperDto`](GLOSSARY.md#paperdto) and [`ResultDto`](GLOSSARY.md#resultdto) glossary entries. + +## Input contract — `PaperDto` / `toPaper` + +Non-WordPress consumers can send a documented, serializable input shape — a `PaperDto` — instead of constructing a `Paper` by hand. + +```js +import { toPaper } from "yoastseo/contract"; + +// `toPaper` validates the input and returns an engine `Paper`. +const paper = toPaper( { + text: "Text to analyze", + keyphrase: "analyze", + locale: "en_US", +} ); + +// `paper` can now be passed to `worker.analyze( paper )` or `assessor.assess( paper )`. +``` + +Notes: +- **Covers the analysis inputs.** The neutral core is `text`, `keyphrase`, `synonyms`, `locale`, `description`, `title`, `slug`, `permalink`, `titleWidth`, `textTitle`, `date`, `writingDirection`, and an open `customData` object. The contract also carries optional, **deprecated** WordPress-transitional fields (`wpBlocks`, `shortcodes`, `isFrontPage`): they are real analysis inputs that change WordPress scores, so a remote/API analysis needs them to reproduce in-browser results. They are marked deprecated. Non-WordPress consumers simply omit them. +- **`keyphrase` is the canonical field name.** `keyword` is accepted as a deprecated alias so existing consumers can adopt the contract without renaming. +- **Validation.** `toPaper` throws on structurally invalid input (wrong types, unknown keys). Omitting an optional field is fine — the assessments that need it are simply skipped, matching the engine's existing behaviour. +- **Extensible.** A consumer that registers its own assessments can validate extra fields by extending the schema: + ```js + import { z } from "zod"; + import { paperDtoSchema, createToPaper } from "yoastseo/contract"; + + const toPaper = createToPaper( paperDtoSchema.extend( { myField: z.string() } ) ); + const paper = toPaper( { text: "…", myField: "…" } ); // `myField` is validated and available on the Paper + ``` + +## Output contract — `ResultDto` / `toResultDto` + +The same entry point exposes a documented, serializable **output** shape — a `ResultDto` — for the results an assessor returns. It is the result-side sibling of `PaperDto`: instead of each consumer hand-rolling a view model from an `AssessmentResult`, the `toResultDto` boundary maps one result to a stable, consumer-facing shape. + +```js +import { toResultDto } from "yoastseo/contract"; + +const results = seoAssessor.getValidResults().map( toResultDto ); +// => [ { identifier, score, rating, text, marks, editFieldName, editFieldAriaLabel, isOptimizable, isBeta }, … ] +``` + +Notes: +- **`rating` is interpreted in the boundary.** It is a pure function of `score` (`error`/`feedback`/`bad`/`ok`/`good`), computed by `toResultDto` and never stored on the result, so it cannot drift from `score`. Consumers no longer need to call `interpreters.scoreToRating` themselves. +- **Neutral signal names.** `isOptimizable` (an automated fix is available for this result) and `isBeta` (the assessment is still in beta) are the contract names for the engine signals exposed by the deprecated `AssessmentResult#hasAIFixes`/`#hasBetaBadge` getters. Presentation stays a consumer concern. +- **`marks`** carry the highlighting payload (`original`, `marked`, `fieldsToMark`, optional `position`) in a transport-agnostic shape. `editFieldName` is the neutral target field for an edit/jump action (an empty string when the result has none). +- **No separate edit-affordance flag.** There is intentionally no `hasJumps`-style boolean: render the edit/jump action when `editFieldName` is non-empty (`Boolean( result.editFieldName )`). The engine only ever sets a jump target together with the affordance, so the presence of `editFieldName` is the single source of truth. +- **i18n caveat.** Like `text`, `editFieldAriaLabel` is a pre-translated (`wordpress-seo` textdomain) string carried as-is for now; a future i18n contract may replace it with a stable key derived from `editFieldName`. + +> **Deprecation — `AssessmentResult#hasAIFixes()` / `#hasBetaBadge()`.** These UI-branded getters are deprecated in favour of the neutral `isOptimizable()` / `isBeta()`. Consumers that read the signals directly off an `AssessmentResult` (instead of via `ResultDto`) should migrate. The old getters still return the same values but log a once-per-session console deprecation warning, and will be removed in a future major version. The `setHasAIFixes`/`setHasBetaBadge` setters and the worker-transport (`serialize`/`parse`) keys are unchanged. diff --git a/packages/yoastseo/DESIGN DECISIONS.md b/packages/yoastseo/docs/DESIGN DECISIONS.md similarity index 100% rename from packages/yoastseo/DESIGN DECISIONS.md rename to packages/yoastseo/docs/DESIGN DECISIONS.md diff --git a/packages/yoastseo/GLOSSARY.md b/packages/yoastseo/docs/GLOSSARY.md similarity index 79% rename from packages/yoastseo/GLOSSARY.md rename to packages/yoastseo/docs/GLOSSARY.md index adaa747b033..d7229577e4a 100644 --- a/packages/yoastseo/GLOSSARY.md +++ b/packages/yoastseo/docs/GLOSSARY.md @@ -27,8 +27,8 @@ const paper = new Paper("
This is the main content
", {
});
```
-### 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.
+### 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.
@@ -49,6 +49,24 @@ const paper = toPaper({
});
```
+### ResultDto
+A documented, serializable **output contract** for the engine, exposed via the same opt-in `yoastseo/contract` entry — the result-side sibling of [PaperDto](#paperdto). The `toResultDto` boundary maps a single engine [AssessmentResult](#assessment) onto the stable shape a non-WordPress consumer reads, so each consumer no longer hand-rolls its own view model.
+
+Key points:
+- Carries `identifier`, `score`, the interpreted `rating`, `text`, `marks`, `editFieldName`/`editFieldAriaLabel` (empty strings when none), and the neutral signals `isOptimizable` and `isBeta`.
+- **`rating`** is computed in the boundary (a pure function of `score`) and never stored, so it cannot drift from `score`.
+- **`isOptimizable`** / **`isBeta`** are the neutral contract names for the per-result signals behind the deprecated `AssessmentResult#hasAIFixes` / `#hasBetaBadge` getters.
+- Authored in [zod](https://zod.dev); `marks` are serialized into a transport-agnostic shape.
+- **i18n caveat:** `editFieldAriaLabel` (like `text`) is a pre-translated string carried as-is for now; a future i18n contract may replace it with a stable key derived from `editFieldName`.
+- **No edit-affordance flag:** there is deliberately no `hasJumps`-style boolean — derive the affordance from `editFieldName` being non-empty (`Boolean( result.editFieldName )`), which is the single source of truth.
+
+**Example:**
+```javascript
+import { toResultDto } from "yoastseo/contract";
+
+const results = seoAssessor.getValidResults().map( toResultDto );
+```
+
### 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)
diff --git a/packages/yoastseo/MORPHOLOGY.md b/packages/yoastseo/docs/MORPHOLOGY.md
similarity index 100%
rename from packages/yoastseo/MORPHOLOGY.md
rename to packages/yoastseo/docs/MORPHOLOGY.md
diff --git a/packages/yoastseo/OVERVIEW.md b/packages/yoastseo/docs/OVERVIEW.md
similarity index 68%
rename from packages/yoastseo/OVERVIEW.md
rename to packages/yoastseo/docs/OVERVIEW.md
index 3872ee12b7f..3a760b34ccc 100644
--- a/packages/yoastseo/OVERVIEW.md
+++ b/packages/yoastseo/docs/OVERVIEW.md
@@ -6,21 +6,21 @@ YoastSEO.js is a text analysis and SEO assessment library that helps improve con
### Core Concepts
-- **[Paper](./GLOSSARY.md#paper)**: A value object representing the text content to be analyzed, including metadata like title, meta description, keyword, etc.
-- **[Assessment](./GLOSSARY.md#assessment)**: An individual analysis that evaluates a specific aspect of content (e.g., keyword density, sentence length)
-- **[Assessor](./GLOSSARY.md#assessor)**: A collection of assessments that work together to analyze content from a specific angle (SEO, readability, etc.)
-- **[Researcher](./GLOSSARY.md#researcher)**: Performs linguistic research on text content (e.g., sentence detection, word counting)
-- **[Score](./GLOSSARY.md#score)**: A numeric value (0-100) indicating how well content performs for a specific assessment
-- **[Marker](./GLOSSARY.md#marker)**: Highlights relevant portions of text for specific assessments
+- **[Paper](GLOSSARY.md#paper)**: A value object representing the text content to be analyzed, including metadata like title, meta description, keyword, etc.
+- **[Assessment](GLOSSARY.md#assessment)**: An individual analysis that evaluates a specific aspect of content (e.g., keyword density, sentence length)
+- **[Assessor](GLOSSARY.md#assessor)**: A collection of assessments that work together to analyze content from a specific angle (SEO, readability, etc.)
+- **[Researcher](GLOSSARY.md#researcher)**: Performs linguistic research on text content (e.g., sentence detection, word counting)
+- **[Score](GLOSSARY.md#score)**: A numeric value (0-100) indicating how well content performs for a specific assessment
+- **[Marker](GLOSSARY.md#marker)**: Highlights relevant portions of text for specific assessments
### Linguistic Concepts
-- **[Morphology](./GLOSSARY.md#morphology)**: Study of word forms and structure (stems, affixes, etc.)
-- **[Stem](./GLOSSARY.md#stem)**: The base form of a word before any affixes are added
-- **[Function Words](./GLOSSARY.md#function-words)**: Words with little semantic meaning that primarily serve grammatical purposes (e.g., articles, prepositions)
-- **[Content Words](./GLOSSARY.md#content-words)**: Words that carry semantic meaning (nouns, verbs, adjectives, etc.)
-- **[Keyphrase](./GLOSSARY.md#keyphrase)**: The main topic or search term being targeted in the content
-- **[Synonym](./GLOSSARY.md#synonym)**: Alternative words or phrases with similar meaning to the keyphrase
+- **[Morphology](GLOSSARY.md#morphology)**: Study of word forms and structure (stems, affixes, etc.)
+- **[Stem](GLOSSARY.md#stem)**: The base form of a word before any affixes are added
+- **[Function Words](GLOSSARY.md#function-words)**: Words with little semantic meaning that primarily serve grammatical purposes (e.g., articles, prepositions)
+- **[Content Words](GLOSSARY.md#content-words)**: Words that carry semantic meaning (nouns, verbs, adjectives, etc.)
+- **[Keyphrase](GLOSSARY.md#keyphrase)**: The main topic or search term being targeted in the content
+- **[Synonym](GLOSSARY.md#synonym)**: Alternative words or phrases with similar meaning to the keyphrase
## Architecture Diagrams
@@ -106,7 +106,7 @@ const results = assessor.getValidResults();
> const Researcher = getResearcher( "nl" ); // Returns the class; falls back to the default Researcher for unsupported languages.
> const researcher = new Researcher( paper );
> ```
-> See the [Entry points](./README.md#entry-points) section of the README for why this is a separate entry.
+> See the [Entry points](../README.md#entry-points) section of the README for why this is a separate entry.
## Key Features
@@ -146,7 +146,7 @@ The library can be integrated in several ways:
4. **Custom CMS Integration**: Can be integrated into any CMS or editing environment
For more detailed documentation on specific topics, see:
-- [Public entry points](./README.md#entry-points) (`yoastseo` and `yoastseo/researcher`)
-- [Assessments Documentation](./src/scoring/assessments/README.md)
-- [Morphology Documentation](./MORPHOLOGY.md)
+- [Public entry points](../README.md#entry-points) (`yoastseo` and `yoastseo/researcher`)
+- [Assessments Documentation](../src/scoring/assessments/README.md)
+- [Morphology Documentation](MORPHOLOGY.md)
- [Design Decisions](./DESIGN%20DECISIONS.md)
diff --git a/packages/yoastseo/package.json b/packages/yoastseo/package.json
index 612c83deaa9..32bb8d1b559 100644
--- a/packages/yoastseo/package.json
+++ b/packages/yoastseo/package.json
@@ -23,7 +23,8 @@
"vendor",
"images",
"researcher",
- "contract"
+ "contract",
+ "docs/**/*.md"
],
"sideEffects": false,
"scripts": {
diff --git a/packages/yoastseo/spec/contract/paperDtoSpec.js b/packages/yoastseo/spec/contract/paperDtoSpec.js
index 6a6704d7997..35190381d8a 100644
--- a/packages/yoastseo/spec/contract/paperDtoSpec.js
+++ b/packages/yoastseo/spec/contract/paperDtoSpec.js
@@ -1,7 +1,7 @@
import Paper from "../../src/values/Paper.js";
import { paperDtoSchema, toPaper } from "../../src/contract";
-describe( "the Paper input contract (PaperDTO)", function() {
+describe( "the Paper input contract (PaperDto)", function() {
describe( "toPaper", function() {
it( "maps a valid keyphrase-core DTO onto a Paper", function() {
const paper = toPaper( {
diff --git a/packages/yoastseo/spec/contract/resultDtoSpec.js b/packages/yoastseo/spec/contract/resultDtoSpec.js
new file mode 100644
index 00000000000..f668f8ab91d
--- /dev/null
+++ b/packages/yoastseo/spec/contract/resultDtoSpec.js
@@ -0,0 +1,104 @@
+import { resultDtoSchema, toResultDto } from "../../src/contract";
+import AssessmentResult from "../../src/values/AssessmentResult.js";
+import Mark from "../../src/values/Mark.js";
+
+describe( "the result output contract (ResultDto)", function() {
+ describe( "toResultDto", function() {
+ it( "maps a valid AssessmentResult onto a ResultDto", function() {
+ const result = new AssessmentResult( { score: 9, text: "Great keyphrase length!" } );
+ result.setIdentifier( "keyphraseLength" );
+
+ expect( toResultDto( result ) ).toEqual( {
+ identifier: "keyphraseLength",
+ score: 9,
+ rating: "good",
+ text: "Great keyphrase length!",
+ marks: [],
+ editFieldName: "",
+ editFieldAriaLabel: "",
+ isOptimizable: false,
+ isBeta: false,
+ } );
+ } );
+
+ // `rating` is interpreted in the boundary, never stored, so it cannot drift from `score`.
+ [ [ -1, "error" ], [ 0, "feedback" ], [ 4, "bad" ], [ 6, "ok" ], [ 9, "good" ] ].forEach( function( [ score, rating ] ) {
+ it( `derives rating "${ rating }" from score ${ score }`, function() {
+ const result = new AssessmentResult( { score, text: "A feedback message." } );
+
+ expect( toResultDto( result ).rating ).toBe( rating );
+ } );
+ } );
+
+ it( "includes editFieldName when the result has one", function() {
+ const result = new AssessmentResult( { score: 3, text: "x", editFieldName: "slug" } );
+
+ expect( toResultDto( result ).editFieldName ).toBe( "slug" );
+ } );
+
+ it( "defaults editFieldName to an empty string when the result has none", function() {
+ const result = new AssessmentResult( { score: 3, text: "x" } );
+
+ expect( toResultDto( result ).editFieldName ).toBe( "" );
+ } );
+
+ it( "includes editFieldAriaLabel when the result has one", function() {
+ const result = new AssessmentResult( { score: 3, text: "x", editFieldAriaLabel: "Edit the slug" } );
+
+ expect( toResultDto( result ).editFieldAriaLabel ).toBe( "Edit the slug" );
+ } );
+
+ it( "defaults editFieldAriaLabel to an empty string when the result has none", function() {
+ const result = new AssessmentResult( { score: 3, text: "x" } );
+
+ expect( toResultDto( result ).editFieldAriaLabel ).toBe( "" );
+ } );
+
+ it( "serializes marks into their transport-agnostic shape (no _parseClass)", function() {
+ const mark = new Mark( { original: "cat", marked: "