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 ![Screenshot of the assessment of the given text](images/assessments.png) ## 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: "cat" } ); + const result = new AssessmentResult( { score: 3, text: "x", marks: [ mark ] } ); + + const dto = toResultDto( result ); + + expect( dto.marks ).toEqual( [ { original: "cat", marked: "cat", fieldsToMark: [] } ] ); + expect( dto.marks[ 0 ] ).not.toHaveProperty( "_parseClass" ); + } ); + + it( "maps the neutral isOptimizable/isBeta signals off the engine getters", function() { + const result = new AssessmentResult( { score: 3, text: "x", _hasAIFixes: true, _hasBetaBadge: true } ); + + const dto = toResultDto( result ); + + expect( dto.isOptimizable ).toBe( true ); + expect( dto.isBeta ).toBe( true ); + } ); + } ); + + describe( "resultDtoSchema", function() { + it( "accepts a minimal valid payload and applies defaults", function() { + expect( resultDtoSchema.parse( { identifier: "id", score: 5, rating: "ok", text: "x" } ) ).toEqual( { + identifier: "id", + score: 5, + rating: "ok", + text: "x", + marks: [], + editFieldName: "", + editFieldAriaLabel: "", + isOptimizable: false, + isBeta: false, + } ); + } ); + + it( "rejects an unknown rating value", function() { + expect( () => resultDtoSchema.parse( { identifier: "id", score: 5, rating: "great", text: "x" } ) ).toThrow(); + } ); + + it( "rejects structurally invalid payloads (wrong types)", function() { + expect( () => resultDtoSchema.parse( { identifier: 1, score: "x", rating: "ok", text: "x" } ) ).toThrow(); + } ); + + it( "requires the core fields (missing text throws)", function() { + expect( () => resultDtoSchema.parse( { identifier: "id", score: 5, rating: "ok" } ) ).toThrow(); + } ); + } ); +} ); diff --git a/packages/yoastseo/spec/values/AssessmentResultSpec.js b/packages/yoastseo/spec/values/AssessmentResultSpec.js index e4e942808c3..0ab8bb47066 100644 --- a/packages/yoastseo/spec/values/AssessmentResultSpec.js +++ b/packages/yoastseo/spec/values/AssessmentResultSpec.js @@ -143,4 +143,36 @@ describe( "AssessmentResult", function() { expect( result.hasAIFixes() ).toBe( true ); } ); } ); + + describe( "isBeta", function() { + it( "defaults to false", function() { + const result = new AssessmentResult(); + + expect( result.isBeta() ).toBe( false ); + } ); + + it( "mirrors the beta-badge value (the neutral replacement for hasBetaBadge)", function() { + const result = new AssessmentResult(); + + result.setHasBetaBadge( true ); + + expect( result.isBeta() ).toBe( true ); + } ); + } ); + + describe( "isOptimizable", function() { + it( "defaults to false", function() { + const result = new AssessmentResult(); + + expect( result.isOptimizable() ).toBe( false ); + } ); + + it( "mirrors the AI-fixes value (the neutral replacement for hasAIFixes)", function() { + const result = new AssessmentResult(); + + result.setHasAIFixes( true ); + + expect( result.isOptimizable() ).toBe( true ); + } ); + } ); } ); diff --git a/packages/yoastseo/src/contract/index.js b/packages/yoastseo/src/contract/index.js index 847d2a7f021..1d598725441 100644 --- a/packages/yoastseo/src/contract/index.js +++ b/packages/yoastseo/src/contract/index.js @@ -1 +1,2 @@ export { paperDtoSchema, toPaper } from "./paperDto.js"; +export { resultDtoSchema, toResultDto } from "./resultDto.js"; diff --git a/packages/yoastseo/src/contract/paperDto.js b/packages/yoastseo/src/contract/paperDto.js index 15ee35f9093..178a7e27486 100644 --- a/packages/yoastseo/src/contract/paperDto.js +++ b/packages/yoastseo/src/contract/paperDto.js @@ -50,7 +50,7 @@ export const paperDtoSchema = z.object( { } ).strict(); /** - * @typedef {import("zod").infer} PaperDTO + * @typedef {import("zod").infer} PaperDto */ /** @@ -62,7 +62,7 @@ export const paperDtoSchema = z.object( { * Absent optional fields are left to Paper's own defaults, so missing inputs degrade * gracefully rather than throwing. * - * @param {PaperDTO} dto The serializable input contract. + * @param {PaperDto} dto The serializable input contract. * @returns {Paper} The constructed Paper. */ export function toPaper( dto ) { diff --git a/packages/yoastseo/src/contract/resultDto.js b/packages/yoastseo/src/contract/resultDto.js new file mode 100644 index 00000000000..b904951f59c --- /dev/null +++ b/packages/yoastseo/src/contract/resultDto.js @@ -0,0 +1,85 @@ +import { z } from "zod"; +import scoreToRating from "../scoring/interpreters/scoreToRating.js"; + +/** + * @typedef {import("../values/AssessmentResult").default} AssessmentResult + */ + +/** + * Serializable shape of a single highlighting mark, mirroring `Mark.serialize()`. + * + * `Mark.serialize()` also emits a `_parseClass` transport key. `z.object()` strips unknown keys on parse + * by default, so it is dropped here, leaving a clean, transport-agnostic shape (`.strict()` would throw on it instead). + */ +const markSchema = z.object( { + original: z.string().describe( "The original, unmarked source text." ), + marked: z.string().describe( "The text with highlighting markup applied." ), + fieldsToMark: z.array( z.string() ).optional().describe( "The fields the mark applies to." ), + position: z.unknown().optional().describe( "Source-code range for position-based highlighting." ), +} ); + +/** + * Serializable output contract for the analysis engine: the stable shape of a single `AssessmentResult`. + * + * This is the result-side sibling of the `PaperDto` input contract. It exposes only the result-intrinsic + * surface a non-WordPress consumer needs, and folds two interpretation/selection concerns into the boundary + * so they cannot drift: + * - `rating` is the interpreted score (a pure function of `score`); it is computed in {@link toResultDto} + * and never stored on the result, so it can never disagree with `score`. + * - `isOptimizable` and `isBeta` are the engine's neutral per-result UI-eligibility signals (mapped from the + * deprecated `hasAIFixes()`/`hasBetaBadge()` getters); presentation stays a consumer concern. + * + * The schema is deliberately NOT `.strict()`: unlike the input contract, the engine produces this payload, so + * there is no consumer typo to catch. + */ +export const resultDtoSchema = z.object( { + identifier: z.string().describe( "Stable assessment id, e.g. 'keyphraseLength'." ), + score: z.number().describe( "Raw engine score. Prefer `rating` for display." ), + rating: z.enum( [ "error", "feedback", "bad", "ok", "good" ] ) + .describe( "Interpreted score: error=-1, feedback=0, bad=1-4, ok=5-7, good>7." ), + text: z.string().describe( "Feedback message for this result." ), + marks: z.array( markSchema ).default( [] ).describe( "Highlighting payload for this result." ), + editFieldName: z.string().default( "" ) + .describe( "Neutral target field for the edit/jump action ('' when none); matches the input-contract field names." ), + editFieldAriaLabel: z.string().default( "" ) + .describe( "Pre-translated aria-label for the edit/jump action ('' when none). i18n caveat: like `text`, this " + + "is a translated string; a future i18n contract may replace it with a key derived from `editFieldName`." ), + isOptimizable: z.boolean().default( false ) + .describe( "Whether an automated fix is available for this result (engine-computed, score-gated)." ), + isBeta: z.boolean().default( false ) + .describe( "Whether this result is from an assessment still in beta/experimental status." ), +} ); + +/** + * @typedef {import("zod").infer} ResultDto + */ + +/** + * Maps an engine `AssessmentResult` onto the stable, serializable `ResultDto`. + * + * This is the single place that knows how the engine's result surface lands on the contract: it interprets + * `score` into `rating`, maps the neutral `isOptimizable`/`isBeta` signals off the (deprecated) branded + * getters, and serializes marks into their transport-agnostic shape. The value object stays uninterpreted. + * + * Intended for results from `assessor.getValidResults()`, which only yields results with a numeric score, so + * `rating` is always one of the enum values. Throws a `ZodError` if the produced payload is structurally + * invalid. + * + * @param {AssessmentResult} result The result to map. + * + * @returns {ResultDto} The validated, consumer-facing result. + */ +export function toResultDto( result ) { + return resultDtoSchema.parse( { + identifier: result.getIdentifier(), + score: result.getScore(), + rating: scoreToRating( result.getScore() ), + text: result.getText(), + marks: result.getMarks().map( mark => mark.serialize() ), + // Always present (the getters return "" when unset), so the shape stays stable for consumers. + editFieldName: result.getEditFieldName(), + editFieldAriaLabel: result.getEditFieldAriaLabel(), + isOptimizable: result.isOptimizable(), + isBeta: result.isBeta(), + } ); +} diff --git a/packages/yoastseo/src/scoring/assessments/KEYPHRASE MATCHING.md b/packages/yoastseo/src/scoring/assessments/KEYPHRASE MATCHING.md index 91bf9cb5c3a..d71e2261f98 100644 --- a/packages/yoastseo/src/scoring/assessments/KEYPHRASE MATCHING.md +++ b/packages/yoastseo/src/scoring/assessments/KEYPHRASE MATCHING.md @@ -33,7 +33,7 @@ In addition to per-word matching, we also filter out **function words** from the In addition to the above, Premium users can add **synonyms** of their keyphrase. ### Group 3: Languages with word form support -Here is the list of [languages with word form support](https://github.com/Yoast/wordpress-seo/tree/trunk/packages/yoastseo/MORPHOLOGY.md). +Here is the list of [languages with word form support](../../../docs/MORPHOLOGY.md). #### Free Free users with languages that have word form support have access to the same functionalities as Free users of group 2 (filtering out **function words** and **per-word matching**). diff --git a/packages/yoastseo/src/values/AssessmentResult.js b/packages/yoastseo/src/values/AssessmentResult.js index b273042db62..ead361a64b5 100644 --- a/packages/yoastseo/src/values/AssessmentResult.js +++ b/packages/yoastseo/src/values/AssessmentResult.js @@ -3,12 +3,50 @@ import { isArray, isNumber, isUndefined } from "lodash"; import Mark from "./Mark"; /** - * A function that only returns an empty that can be used as an empty marker + * @typedef {Object} SerializedAssessmentResult + * @property {string} [_parseClass] The name of the class, used for parsing. Should be "AssessmentResult" for AssessmentResult instances. + * @property {string} identifier The identifier of the assessment result. + * @property {boolean} _hasJumps Whether the result causes a jump to a different field. + * @property {boolean} _hasAIFixes Whether the assessment result has AI fixes. + * @property {boolean} _hasBetaBadge Whether the assessment is in beta. + * @property {string} editFieldName The edit field name for this assessment result, used to determine where an edit button should jump to when clicked. + * @property {string} editFieldAriaLabel The edit field aria label for this assessment result. + * @property {number} score The score for this assessment result. + * @property {string} text The feedback text for this assessment result. + * @property {Object[]} marks The serialized marks (the output of Mark.serialize()). + */ + +/** + * A function that only returns an empty array that can be used as an empty marker. * - * @returns {Array} A list of empty marks. + * @returns {Mark[]} A list of empty marks. */ const emptyMarker = () => []; +/** + * Tracks which deprecated getters have already logged a notice, so the warning is emitted once per + * session instead of on every analysis result (these getters are read per result, e.g., in result mappers). + * + * @type {Object} + */ +const deprecationNoticed = {}; + +/** + * Logs a one-time deprecation notice for a getter that has been renamed to a contract-neutral name. + * + * @param {string} oldName The deprecated getter name. + * @param {string} newName The replacement getter name. + * + * @returns {void} + */ +function warnRenamedGetterOnce( oldName, newName ) { + if ( deprecationNoticed[ oldName ] ) { + return; + } + deprecationNoticed[ oldName ] = true; + console.warn( `AssessmentResult.${ oldName }() is deprecated; use ${ newName }() instead.` ); +} + /** * Represents the assessment result. */ @@ -19,16 +57,15 @@ class AssessmentResult { * @param {Object} [values] The values for this assessment result. * @param {number} [values.score] The score for this assessment result. * @param {string} [values.text] The text for this assessment result. This is the text that can be used as a feedback message associated with the score. - * @param {array} [values.marks] The marks for this assessment result. + * @param {Mark[]} [values.marks] The marks for this assessment result. * @param {boolean} [values._hasBetaBadge] Whether this result has a beta badge. * @param {boolean} [values._hasJumps] Whether this result causes a jump to a different field. * @param {string} [values.editFieldName] The edit field name for this assessment result. * @param {string} [values.editFieldAriaLabel] The edit field aria label for this assessment result. * @param {boolean} [values._hasAIFixes] Whether this result has AI fixes. * @constructor - * @returns {void} */ - constructor( values ) { + constructor( values = {} ) { this._hasScore = false; this._identifier = ""; this._hasAIFixes = false; @@ -40,14 +77,30 @@ class AssessmentResult { this._hasBetaBadge = false; this.score = 0; this.text = ""; + /** + * @type {Mark[]} + */ this.marks = []; this.editFieldName = ""; this.editFieldAriaLabel = ""; + this._setValues( values ); + } - if ( isUndefined( values ) ) { - values = {}; - } - + /** + * Sets the values for the AssessmentResult. + * @internal + * @param {Object} values The values for this assessment result. + * @param {number} [values.score] The score for this assessment result. + * @param {string} [values.text] The text for this assessment result. This is the text that can be used as a feedback message associated with the score. + * @param {Mark[]} [values.marks] The marks for this assessment result. + * @param {boolean} [values._hasBetaBadge] Whether this result has a beta badge. + * @param {boolean} [values._hasJumps] Whether this result causes a jump to a different field. + * @param {string} [values.editFieldName] The edit field name for this assessment result. + * @param {string} [values.editFieldAriaLabel] The edit field aria label for this assessment result. + * @param {boolean} [values._hasAIFixes] Whether this result has AI fixes. + * @private + */ + _setValues( values ) { if ( ! isUndefined( values.score ) ) { this.setScore( values.score ); } @@ -141,7 +194,7 @@ class AssessmentResult { /** * Gets the available marks. * - * @returns {array} The marks associated with the AssessmentResult. + * @returns {Mark[]} The marks associated with the AssessmentResult. */ getMarks() { return this.marks; @@ -150,7 +203,7 @@ class AssessmentResult { /** * Sets the marks for the assessment. * - * @param {array} marks The marks to be used for the marks property. + * @param {Mark[]} marks The marks to be used for the marks property. * * @returns {void} */ @@ -183,7 +236,7 @@ class AssessmentResult { /** * Sets the marker, a pure function that can return the marks for a given Paper. * - * @param {Function} marker The marker to set. + * @param {()=>[]} marker The marker to set. * @returns {void} */ setMarker( marker ) { @@ -240,9 +293,25 @@ class AssessmentResult { /** * Returns the value of _hasBetaBadge to determine if the result has a beta badge. * - * @returns {bool} Whether this result has a beta badge. + * @deprecated Use {@link AssessmentResult#isBeta} instead. The result contract exposes this signal under + * the neutral `isBeta` name; this UI-branded getter is kept for backwards compatibility and will be + * removed at a future major version. + * + * @returns {boolean} Whether this result has a beta badge. */ hasBetaBadge() { + warnRenamedGetterOnce( "hasBetaBadge", "isBeta" ); + return this._hasBetaBadge; + } + + /** + * Returns whether this result belongs to an assessment that is still in beta/experimental status. + * + * Replaces the UI-branded {@link AssessmentResult#hasBetaBadge}. + * + * @returns {boolean} Whether this result is from a beta assessment. + */ + isBeta() { return this._hasBetaBadge; } @@ -259,14 +328,14 @@ class AssessmentResult { /** * Returns the value of _hasJumps to determine whether it's needed to jump to a different field. * - * @returns {bool} Whether this result causes a jump to a different field. + * @returns {boolean} Whether this result causes a jump to a different field. */ hasJumps() { return this._hasJumps; } /** - * Check if an edit field name is available. + * Checks if an edit field name is available. * @returns {boolean} Whether or not an edit field name is available. */ hasEditFieldName() { @@ -294,7 +363,7 @@ class AssessmentResult { } /** - * Check if an edit field aria label is available. + * Checks if an edit field aria label is available. * @returns {boolean} Whether or not an edit field aria label is available. */ hasEditFieldAriaLabel() { @@ -334,16 +403,33 @@ class AssessmentResult { /** * Returns the value of _hasAIFixes to determine if the result has AI fixes. * - * @returns {bool} Whether this result has AI fixes. + * @deprecated Use {@link AssessmentResult#isOptimizable} instead. The result contract exposes this signal + * under the neutral `isOptimizable` name; this Yoast-AI-branded getter is kept for backwards + * compatibility and will be removed at a future major version. + * + * @returns {boolean} Whether this result has AI fixes. */ hasAIFixes() { + warnRenamedGetterOnce( "hasAIFixes", "isOptimizable" ); + return this._hasAIFixes; + } + + /** + * Returns whether this result is optimizable, i.e., whether an automated fix is available for it. + * + * Replaces the Yoast-AI-branded {@link AssessmentResult#hasAIFixes}. Eligibility is computed by the + * assessment from the result's score, so it stays an engine-side signal a consumer cannot reconstruct. + * + * @returns {boolean} Whether an automated fix is available for this result. + */ + isOptimizable() { return this._hasAIFixes; } /** * Serializes the AssessmentResult instance to an object. * - * @returns {Object} The serialized AssessmentResult. + * @returns {SerializedAssessmentResult} The serialized AssessmentResult. */ serialize() { return { @@ -363,7 +449,7 @@ class AssessmentResult { /** * Parses the object to an AssessmentResult. * - * @param {Object} serialized The serialized object. + * @param {SerializedAssessmentResult} serialized The serialized object. * * @returns {AssessmentResult} The parsed AssessmentResult. */