0
0
mirror of https://github.com/wagtail/wagtail.git synced 2024-11-21 09:59:02 +01:00

Add content metrics board (#12058)

This commit is contained in:
Albina 2024-07-11 16:58:19 +03:00 committed by GitHub
parent 15378899e2
commit 14f3d4607f
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
9 changed files with 273 additions and 8 deletions

View File

@ -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)

View File

@ -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);
};

View File

@ -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);
});
});

View File

@ -0,0 +1,118 @@
import axe from 'axe-core';
import { ngettext } from '../utils/gettext';
export const getWordCount = (lang: string, text: string): number => {
// Firefox ESR doesnt 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 isnt 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}`);
};

View File

@ -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 plugins logic in the one frame.
* - The preview frame only executes the plugins logic, it doesnt 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,
);
},
},
],
};

View File

@ -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);

View File

@ -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 editors 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)

View File

@ -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,

View File

@ -15,9 +15,27 @@
<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" }}