diff --git a/CHANGELOG.txt b/CHANGELOG.txt index 53e76d482d45..59e1433f1a8b 100644 --- a/CHANGELOG.txt +++ b/CHANGELOG.txt @@ -22,6 +22,7 @@ Changelog * Implement universal listings UI for report views (Sage Abdullah) * Make `routable_resolver_match` attribute available on RoutablePageMixin responses (Andy Chosak) * Support customizations to `UserViewSet` via the app config (Sage Abdullah) + * Add word count and reading time metrics within the page editor (Albina Starykova. Sponsored by The Motley Fool) * Fix: Make `WAGTAILIMAGES_CHOOSER_PAGE_SIZE` setting functional again (Rohit Sharma) * Fix: Enable `richtext` template tag to convert lazy translation values (Benjamin Bach) * Fix: Ensure permission labels on group permissions page are translated where available (Matt Westcott) diff --git a/client/src/entrypoints/admin/preview-panel.js b/client/src/entrypoints/admin/preview-panel.js index e887d38d3671..9c0ddce22a68 100644 --- a/client/src/entrypoints/admin/preview-panel.js +++ b/client/src/entrypoints/admin/preview-panel.js @@ -1,12 +1,32 @@ +import axe from 'axe-core'; + import { getAxeConfiguration, getA11yReport, renderA11yResults, } from '../../includes/a11y-result'; +import { wagtailPreviewPlugin } from '../../includes/previewPlugin'; +import { + getPreviewContentMetrics, + renderContentMetrics, +} from '../../includes/contentMetrics'; import { WAGTAIL_CONFIG } from '../../config/wagtailConfig'; import { debounce } from '../../utils/debounce'; import { gettext } from '../../utils/gettext'; +const runContentChecks = async () => { + axe.registerPlugin(wagtailPreviewPlugin); + + const contentMetrics = await getPreviewContentMetrics({ + targetElement: 'main, [role="main"], body', + }); + + renderContentMetrics({ + wordCount: contentMetrics.wordCount, + readingTime: contentMetrics.readingTime, + }); +}; + const runAccessibilityChecks = async (onClickSelector) => { const a11yRowTemplate = document.querySelector('#w-a11y-result-row-template'); const a11ySelectorTemplate = document.querySelector( @@ -201,6 +221,8 @@ function initPreview() { // Remove the load event listener so it doesn't fire when switching modes newIframe.removeEventListener('load', handleLoad); + runContentChecks(); + const onClickSelector = () => newTabButton.click(); runAccessibilityChecks(onClickSelector); }; diff --git a/client/src/includes/contentMetrics.test.ts b/client/src/includes/contentMetrics.test.ts new file mode 100644 index 000000000000..9e55df2cd684 --- /dev/null +++ b/client/src/includes/contentMetrics.test.ts @@ -0,0 +1,40 @@ +import { getWordCount, getReadingTime } from './contentMetrics'; + +describe.each` + text | lang | wordCount + ${'¿Donde esta la biblioteca?'} | ${'es'} | ${4} + ${"It's lots. Of; Punctuation"} | ${'en'} | ${4} + ${'האהבה היא אוקיינוס שאין לו התחלה ואין לו סוף.'} | ${'he'} | ${9} + ${'元気です、ありがとう。あなたは?'} | ${'zh'} | ${5} + ${'Dit is een testzin in het Nederlands.'} | ${'nl'} | ${7} + ${'Je suis content de te voir!'} | ${'fr'} | ${6} + ${'Ich liebe dich!'} | ${'de'} | ${3} + ${'Mi piace molto questo libro.'} | ${'it'} | ${5} + ${'저는 오늘 날씨가 좋아요.'} | ${'ko'} | ${4} + ${'Unknown language code still works'} | ${'invalid'} | ${5} +`('getWordCount', ({ text, lang, wordCount }) => { + test(`correctly counts words in '${text}' for language '${lang}'`, () => { + expect(getWordCount(lang, text)).toBe(wordCount); + }); +}); + +describe.each` + lang | wordCount | readingTime + ${'es'} | ${1000} | ${4} + ${'fr'} | ${1000} | ${5} + ${'ar'} | ${360} | ${2} + ${'it'} | ${360} | ${1} + ${'en'} | ${238} | ${1} + ${'en-us'} | ${238} | ${1} + ${'he'} | ${224} | ${1} + ${'zh'} | ${520} | ${2} + ${'zh-Hans'} | ${520} | ${2} + ${'nl'} | ${320} | ${1} + ${'ko'} | ${50} | ${0} + ${'invalid'} | ${1000} | ${4} + ${''} | ${1000} | ${4} +`('getReadingTime', ({ lang, wordCount, readingTime }) => { + test(`calculates reading time for '${wordCount}' words in language '${lang}'`, () => { + expect(getReadingTime(lang, wordCount)).toBe(readingTime); + }); +}); diff --git a/client/src/includes/contentMetrics.ts b/client/src/includes/contentMetrics.ts new file mode 100644 index 000000000000..97bc531153a0 --- /dev/null +++ b/client/src/includes/contentMetrics.ts @@ -0,0 +1,118 @@ +import axe from 'axe-core'; +import { ngettext } from '../utils/gettext'; + +export const getWordCount = (lang: string, text: string): number => { + // Firefox ESR doesn’t have support for Intl.Segmenter yet. + if (typeof Intl.Segmenter === 'undefined') { + return 0; + } + + const segmenter = new Intl.Segmenter(lang, { granularity: 'word' }); + const segments: Intl.SegmentData[] = Array.from(segmenter.segment(text)); + const wordCount = segments.reduce( + (count, segment) => (segment.isWordLike ? count + 1 : count), + 0, + ); + + return wordCount; +}; + +/* +Language-specific reading speeds according to a meta-analysis of 190 studies on reading rates. +Study preprint: https://osf.io/preprints/psyarxiv/xynwg/ +DOI: https://doi.org/10.1016/j.jml.2019.104047 + */ +const readingSpeeds = { + ar: 181, // Arabic + zh: 260, // Chinese + nl: 228, // Dutch + en: 238, // English + fi: 195, // Finnish + fr: 214, // French + de: 260, // German + he: 224, // Hebrew + it: 285, // Italian + ko: 226, // Korean + es: 278, // Spanish + sv: 218, // Swedish +}; + +export const getReadingTime = (lang: string, wordCount: number): number => { + const locale = lang.split('-')[0]; + // Fallback to English reading speed if the locale is not found + const readingSpeed = readingSpeeds[locale] || readingSpeeds.en; + const readingTime = Math.round(wordCount / readingSpeed); + + return readingTime; +}; + +interface ContentMetricsOptions { + targetElement: string; +} + +interface ContentMetrics { + wordCount: number; + readingTime: number; +} + +export const contentMetricsPluginInstance = { + id: 'metrics', + getMetrics( + options: ContentMetricsOptions, + done: (metrics: ContentMetrics) => void, + ) { + const main = document.querySelector(options.targetElement); + const text = main?.innerText || ''; + const lang = document.documentElement.lang || 'en'; + const wordCount = getWordCount(lang, text); + const readingTime = getReadingTime(lang, wordCount); + done({ + wordCount, + readingTime, + }); + }, +}; + +/** + * Calls the `getMetrics` method in the `metrics` plugin instance of the `wagtailPreview` registry. + * Wrapped in a promise so we can use async/await syntax instead of callbacks + */ +export const getPreviewContentMetrics = ( + options: ContentMetricsOptions, +): Promise => + new Promise((resolve) => { + axe.plugins.wagtailPreview.run( + 'metrics', + 'getMetrics', + options, + (metrics: ContentMetrics) => { + resolve(metrics); + }, + ); + }); + +export const renderContentMetrics = ({ + wordCount, + readingTime, +}: ContentMetrics) => { + // Skip updates if word count isn’t set. + if (!wordCount) { + return; + } + + const wordCountContainer = document.querySelector( + '[data-content-word-count]', + ); + const readingTimeContainer = document.querySelector( + '[data-content-reading-time]', + ); + + if (!wordCountContainer || !readingTimeContainer) return; + + wordCountContainer.textContent = wordCount.toString(); + readingTimeContainer.textContent = ngettext( + '%(num)s min', + '%(num)s mins', + readingTime, + ).replace('%(num)s', `${readingTime}`); +}; diff --git a/client/src/includes/previewPlugin.ts b/client/src/includes/previewPlugin.ts new file mode 100644 index 000000000000..d708e363c2d6 --- /dev/null +++ b/client/src/includes/previewPlugin.ts @@ -0,0 +1,53 @@ +import axe, { AxePlugin } from 'axe-core'; + +/** + * Axe plugin registry for interaction between the page editor and the live preview. + * Compared to other aspects of Axe and other plugins, + * - The parent frame only triggers execution of the plugin’s logic in the one frame. + * - The preview frame only executes the plugin’s logic, it doesn’t go through its own frames. + * See https://github.com/dequelabs/axe-core/blob/master/doc/plugins.md. + */ +export const wagtailPreviewPlugin: AxePlugin = { + id: 'wagtailPreview', + run(id, action, options, callback) { + // Outside the preview frame, we need to send the command to the preview iframe. + const preview = document.querySelector( + '[data-preview-iframe]', + ); + + if (preview) { + // @ts-expect-error Not declared in the official Axe Utils API. + axe.utils.sendCommandToFrame( + preview, + { + command: 'run-wagtailPreview', + parameter: id, + action: action, + options: options, + }, + (results) => { + // Pass the results from the preview iframe to the callback. + callback(results); + }, + ); + } else { + // Inside the preview frame, only call the expected plugin instance method. + // eslint-disable-next-line no-underscore-dangle + const pluginInstance = this._registry[id]; + pluginInstance[action].call(pluginInstance, options, callback); + } + }, + commands: [ + { + id: 'run-wagtailPreview', + callback(data, callback) { + return axe.plugins.wagtailPreview.run( + data.parameter, + data.action, + data.options, + callback, + ); + }, + }, + ], +}; diff --git a/client/src/includes/userbar.ts b/client/src/includes/userbar.ts index 5a138c76dd1d..10c2f7984b73 100644 --- a/client/src/includes/userbar.ts +++ b/client/src/includes/userbar.ts @@ -1,3 +1,5 @@ +import axe from 'axe-core'; + import A11yDialog from 'a11y-dialog'; import { Application } from '@hotwired/stimulus'; import { @@ -5,6 +7,8 @@ import { getA11yReport, renderA11yResults, } from './a11y-result'; +import { wagtailPreviewPlugin } from './previewPlugin'; +import { contentMetricsPluginInstance } from './contentMetrics'; import { DialogController } from '../controllers/DialogController'; import { TeleportController } from '../controllers/TeleportController'; @@ -303,14 +307,16 @@ export class Userbar extends HTMLElement { See documentation: https://github.com/dequelabs/axe-core/tree/develop/doc */ - // Initialise axe accessibility checker + // Initialise Axe async initialiseAxe() { + // Collect content data from the live preview via Axe plugin for content metrics calculation + axe.registerPlugin(wagtailPreviewPlugin); + axe.plugins.wagtailPreview.add(contentMetricsPluginInstance); + const accessibilityTrigger = this.shadowRoot?.getElementById( 'accessibility-trigger', ); - const config = getAxeConfiguration(this.shadowRoot); - if (!this.shadowRoot || !accessibilityTrigger || !config) return; const { results, a11yErrorsNumber } = await getA11yReport(config); diff --git a/docs/releases/6.2.md b/docs/releases/6.2.md index b53731cdc50c..5ef4eeeea974 100644 --- a/docs/releases/6.2.md +++ b/docs/releases/6.2.md @@ -17,6 +17,13 @@ The [built-in accessibility checker](authoring_accessible_content) now enforces This feature was implemented by Albina Starykova, with support from the Wagtail accessibility team. +### Word count and reading time metrics + +The page editor’s Checks panel now displays two content metrics: word count, and reading time. +They are calculated based on the contents of the page preview. + +This feature was developed by Albina Starykova and sponsored by The Motley Fool. + ### Other features * Optimize and consolidate redirects report view into the index view (Jake Howard, Dan Braghis) diff --git a/tsconfig.json b/tsconfig.json index e27a60c91d2a..5d41e591870a 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -5,7 +5,7 @@ "esModuleInterop": true, "forceConsistentCasingInFileNames": true, "jsx": "react", - "lib": ["ES2022", "DOM", "DOM.iterable"], + "lib": ["ES2022", "ES2022.Intl", "DOM", "DOM.iterable"], "moduleResolution": "node", "noImplicitAny": false, // TODO: Enable once all existing code is typed "noUnusedLocals": true, diff --git a/wagtail/admin/templates/wagtailadmin/shared/side_panels/checks.html b/wagtail/admin/templates/wagtailadmin/shared/side_panels/checks.html index 85f17b73b92e..7ed0d988814b 100644 --- a/wagtail/admin/templates/wagtailadmin/shared/side_panels/checks.html +++ b/wagtail/admin/templates/wagtailadmin/shared/side_panels/checks.html @@ -15,9 +15,27 @@

- -
-

{% trans 'Issues found' %}0

-
+
+
+

+ {% trans 'Content metrics' %} +

+
+
+

{% trans 'Words' %}

+

-

+
+
+

{% trans 'Reading time' %}

+

-

+
+
+
+
+

+ {% trans 'Issues found' %}0 +

+
+
{{ axe_configuration|json_script:"accessibility-axe-configuration" }}