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:
parent
15378899e2
commit
14f3d4607f
@ -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)
|
||||
|
@ -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);
|
||||
};
|
||||
|
40
client/src/includes/contentMetrics.test.ts
Normal file
40
client/src/includes/contentMetrics.test.ts
Normal 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);
|
||||
});
|
||||
});
|
118
client/src/includes/contentMetrics.ts
Normal file
118
client/src/includes/contentMetrics.ts
Normal 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 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
client/src/includes/previewPlugin.ts
Normal file
53
client/src/includes/previewPlugin.ts
Normal 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 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,
|
||||
);
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
@ -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);
|
||||
|
@ -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)
|
||||
|
@ -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,
|
||||
|
@ -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" }}
|
||||
|
Loading…
Reference in New Issue
Block a user