Skip to content

Commit

Permalink
Add content metrics board
Browse files Browse the repository at this point in the history
  • Loading branch information
albinazs authored and thibaudcolas committed Jul 11, 2024
1 parent 1537889 commit 6e9ac2f
Show file tree
Hide file tree
Showing 9 changed files with 273 additions and 8 deletions.
1 change: 1 addition & 0 deletions CHANGELOG.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
22 changes: 22 additions & 0 deletions client/src/entrypoints/admin/preview-panel.js
Original file line number Diff line number Diff line change
@@ -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(
Expand Down Expand Up @@ -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);
};
Expand Down
40 changes: 40 additions & 0 deletions client/src/includes/contentMetrics.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
118 changes: 118 additions & 0 deletions client/src/includes/contentMetrics.ts
Original file line number Diff line number Diff line change
@@ -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<HTMLElement>(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<ContentMetrics> =>
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<HTMLElement>(
'[data-content-word-count]',
);
const readingTimeContainer = document.querySelector<HTMLElement>(
'[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}`);
};
53 changes: 53 additions & 0 deletions client/src/includes/previewPlugin.ts
Original file line number Diff line number Diff line change
@@ -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<HTMLIFrameElement>(
'[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,
);
},
},
],
};
12 changes: 9 additions & 3 deletions client/src/includes/userbar.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,14 @@
import axe from 'axe-core';

import A11yDialog from 'a11y-dialog';
import { Application } from '@hotwired/stimulus';
import {
getAxeConfiguration,
getA11yReport,
renderA11yResults,
} from './a11y-result';
import { wagtailPreviewPlugin } from './previewPlugin';
import { contentMetricsPluginInstance } from './contentMetrics';
import { DialogController } from '../controllers/DialogController';
import { TeleportController } from '../controllers/TeleportController';

Expand Down Expand Up @@ -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);
Expand Down
7 changes: 7 additions & 0 deletions docs/releases/6.2.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
2 changes: 1 addition & 1 deletion tsconfig.json
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,9 +15,27 @@ <h3 class="w-a11y-result__header">
<span data-a11y-result-selector-text></span>
</button>
</template>

<div class="w-mt-12">
<h2 class="w-flex w-items-center w-gap-2 w-my-5 w-text-16 w-font-bold"><span>{% trans 'Issues found' %}</span><span class="w-a11y-result__count" data-a11y-result-count>0</span></h2>
<div class="w-flex w-flex-col w-gap-2.5" data-checks-panel></div>
<div class="w-divide-y w-divide-border-furniture w-py-6 w-pl-2 lg:w-pl-8">
<div>
<h2 class="w-my-5 w-text-16 w-font-bold w-text-text-label">
{% trans 'Content metrics' %}
</h2>
<div class="w-flex w-gap-10">
<div>
<h3 class="w-my-2 w-text-14 w-text-text-placeholder">{% trans 'Words' %}</h3>
<p class="w-font-semibold w-text-text-label" data-content-word-count>-</p>
</div>
<div>
<h3 class="w-my-2 w-text-14 w-text-text-placeholder">{% trans 'Reading time' %}</h3>
<p class="w-font-semibold w-text-text-label" data-content-reading-time>-</p>
</div>
</div>
</div>
<div>
<h2 class="w-flex w-items-center w-gap-2 w-my-5 w-text-16 w-font-bold w-text-text-label">
<span>{% trans 'Issues found' %}</span><span class="w-a11y-result__count" data-a11y-result-count>0</span>
</h2>
<div class="w-flex w-flex-col w-gap-2.5" data-checks-panel></div>
</div>
</div>
{{ axe_configuration|json_script:"accessibility-axe-configuration" }}

0 comments on commit 6e9ac2f

Please sign in to comment.