Browse Source

Add content metrics board (#12058)

Albina 8 months ago
parent
commit
14f3d4607f

+ 1 - 0
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)

+ 22 - 0
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);
     };

+ 40 - 0
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);
+  });
+});

+ 118 - 0
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<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 - 0
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<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,
+        );
+      },
+    },
+  ],
+};

+ 9 - 3
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);

+ 7 - 0
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)

+ 1 - 1
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,

+ 22 - 4
wagtail/admin/templates/wagtailadmin/shared/side_panels/checks.html

@@ -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" }}