Using Markdown and Localization in the WordPress Block Editor

Avatar of Leonardo Losoviz
Leonardo Losoviz on

If we need to show documentation to the user directly in the WordPress editor, what is the best way to do it?

Since the block editor is based on React, we may be tempted to use React components and HTML code for the documentation. That is the approach I followed in my previous article, which demonstrated a way to show documentation in a modal window.

But this solution is not flawless, because adding documentation through React components and HTML code could become very verbose, not to mention difficult to maintain. For instance, the modal from the image above contains the documentation in a React component like this:

const CacheControlDescription = () => {
  return (
    <p>The Cache-Control header will contain the minimum max-age value from all fields/directives involved in the request, or <code>no-store</code> if the max-age is 0</p>
  )
}

Using Markdown instead of HTML can make the job easier. For instance, the documentation above could be moved out of the React component, and into a Markdown file like /docs/cache-control.md:

The Cache-Control header will contain the minimum max-age value from all fields/directives involved in the request, or `no-store` if the max-age is 0

What are the benefits and drawbacks of using Markdown compared to pure HTML?

AdvantagesDisadvantages
✅ Writing Markdown is easier and faster than HTML❌ The documentation cannot contain React components
✅ The documentation can be kept separate from the block’s source code (even on a separate repo)❌ We cannot use the __ function (which helps localize the content through .po files) to output text
✅ Copy editors can modify the documentation with no fear of breaking the code
✅ The documentation code isn’t added to the block’s JavaScript asset, which can then load faster

Concerning the drawbacks, not being able to use React components may not be a problem, at least for simple documentation. The lack of localization, however, is a major issue. Text in the React component added through the JavaScript __ function can be extracted and replaced using translations from POT files. Content in Markdown cannot access this functionality.

Supporting localization for documentation is mandatory, so we will need to make up for it. In this article we will pursue two goals:

  • Using Markdown to write documentation (displayed by a block of the WordPress editor)
  • Translating the documentation to the user’s language

Let’s start!

Loading Markdown content

Having created a Markdown file /docs/cache-control.md, we can import its content (already rendered as HTML) and inject it into the React component like this:

import CacheControlDocumentation from '../docs/cache-control.md';


const CacheControlDescription = () => {
  return (
    <div
      dangerouslySetInnerHTML={ { __html: CacheControlDocumentation } }
    />
  );
}

This solution relies on webpack, the module bundler sitting at the core of the WordPress editor.

Please notice that the WordPress editor currently uses webpack 4.42, However, the documentation shown upfront on webpack’s site corresponds to version 5 (which is still in beta). The documentation for version 4 is located at a subsite.

The content is transformed from Markdown to HTML via webpack’s loaders, for which the block needs to customize its webpack configuration, adding the rules to use markdown-loader and html-loader.

To do this, add a file, webpack.config.js, at the root of the block with this code:

// This is the default webpack configuration from Gutenberg
const defaultConfig = require( '@wordpress/scripts/config/webpack.config' );


// Customize adding the required rules for the block
module.exports = {
  ...defaultConfig,
  module: {
    ...defaultConfig.module,
    rules: [
      ...defaultConfig.module.rules,
      {
        test: /\.md$/,
        use: [
          {
            loader: "html-loader"
          },
          {
            loader: "markdown-loader"
          }
        ]
      }
    ],
  },
};

And install the corresponding packages:

npm install --save-dev markdown-loader html-loader

Let’s apply one tiny enhancement while we’re at it. The docs folder could contain the documentation for components located anywhere in the project. To skip having to calculate the relative path from each component to that folder, we can add an alias, @docs, in webpack.config.js to resolve to folder /docs:

const path = require( 'path' );
config.resolve.alias[ '@docs' ] = path.resolve( process.cwd(), 'docs/' )

Now, the imports are simplified:

import CacheControlDocumentation from '@docs/cache-control.md';

That’s it! We can now inject documentation from external Markdown files into the React component.

Translating the documentation to the user’s language

We can’t translate strings through .po files for Markdown content, but there is an alternative: produce different Markdown files for different languages. Then, instead of having a single file (/docs/cache-control.md), we can have one file per language, each stored under the corresponding language code:

  • /docs/en/cache-control.md
  • /docs/fr/cache-control.md
  • /docs/zh/cache-control.md
  • etc.

We could also support translations for both language and region, so that American and British English can have different versions, and default to the language-only version when a translation for a region is not provided (e.g. "en_CA" is handled by "en"):

  • /docs/en_US/cache-control.md
  • /docs/en_GB/cache-control.md
  • /docs/en/cache-control.md

To simplify matters, I’ll only explain how to support different languages, without regions. But the code is pretty much the same.

The code demonstrated in this article can also be seen in the source code of a WordPress plugin I made.

Feeding the user’s language to the block

The user’s language in WordPress can be retrieved from get_locale(). Since the locale includes the language code and the region (such as "en_US"), we parse it to extract the language code by itself:

function get_locale_language(): string 
{
  $localeParts = explode( '_', get_locale() );
  return $localeParts[0];
}

Through wp_localize_script(), we provide the language code to the block, as the userLang property under a global variable (which, in this case, is graphqlApiCacheControl):

// The block was registered as $blockScriptRegistrationName
wp_localize_script(
  $blockScriptRegistrationName,
  'graphqlApiCacheControl',
  [
    'userLang' => get_locale_language(),
  ]
);

Now the user’s language code is available on the block:

const lang = window.graphqlApiCacheControl.userLang; 

Dynamic imports

We can only know the user’s language at runtime. However, the import statement is static, not dynamic. Hence, we cannot do this:

// `lang` contains the user's language
import CacheControlDocumentation from '@docs/${ lang }/cache-control.md';

That said, webpack allows us to dynamically load modules through the import function which, by default, splits out the requested module into a separate chunk (i.e. it is not included within the main compiled build/index.js file) to be loaded lazily.

This behavior is suitable for showing documentation on a modal window, which is triggered by a user action and not loaded up front. import must receive some information on where the module is located, so this code works:

import( `@docs/${ lang }/cache-control.md` ).then( module => {
  // ...
});

But this seemingly similar code does not:

const dynamicModule = `@docs/${ lang }/cache-control.md`
import( dynamicModule ).then( module => {
  // ...
});

The content from the file is accessible under key default of the imported object:

const cacheControlContent = import( `@docs/${ lang }/cache-control.md` ).then( obj => obj.default )

We can generalize this logic into a function called getMarkdownContent, passing the name of the Markdown file alongside the language:

const getMarkdownContent = ( fileName, lang ) => {
  return import( `@docs/${ lang }/${ fileName }.md` )
    .then( obj => obj.default )
} 

Managing the chunks

To keep the block assets organized, let’s keep the documentation chunks grouped in the /docs subfolder (to be created inside the build/ folder), and give them descriptive file names.

Then, having two docs (cache-control.md and cache-purging.md) in three languages (English, French and Mandarin Chinese), the following chunks will be produced:

  • build/docs/en-cache-control-md.js
  • build/docs/fr-cache-control-md.js
  • build/docs/zh-cache-control-md.js
  • build/docs/en-cache-purging-md.js
  • build/docs/fr-cache-purging-md.js
  • build/docs/zh-cache-purging-md.js

This is accomplished by using the magic comment /* webpackChunkName: "docs/[request]" */ just before the import argument:

const getMarkdownContent = ( fileName, lang ) => {
  return import( /* webpackChunkName: "docs/[request]" */ `@docs/${ lang }/${ fileName }.md` )
    .then(obj => obj.default)
} 

Setting the public path for the chunks

webpack knows where to fetch the chunks, thanks to the publicPath configuration option. If it’s not provided, the current URL from the WordPress editor, /wp-admin/, is used, producing a 404 since the chunks are located somewhere else. For my block, they are under /wp-content/plugins/graphql-api/blocks/cache-control/build/.

If the block is for our own use, we can hardcode publicPath in webpack.config.js, or provide it through an ASSET_PATH environment variable. Otherwise, we need to pass the public path to the block at runtime. To do so, we calculate the URL for the block’s build/ folder:

$blockPublicPath = plugin_dir_url( __FILE__ ) . '/blocks/cache-control/build/';

Then we inject it to the JavaScript side by localizing the block:

// The block was registered as $blockScriptRegistrationName
wp_localize_script(
    $blockScriptRegistrationName,
    'graphqlApiCacheControl',
    [
      //...
      'publicPath' => $blockPublicPath,
    ]
);

And then we provide the public path to the __webpack_public_path__ JavaScript variable:

__webpack_public_path__ = window.graphqlApiCacheControl.publicPath;

Falling back to a default language

What would happen if there is no translation for the user’s language? In that case, calling getMarkdownContent will throw an error.

For instance, when the language is set to German, the browser console will display this:

Uncaught (in promise) Error: Cannot find module './de/cache-control.md'

The solution is to catch the error and then return the content in a default language, which is always satisfied by the block:

const getMarkdownContentOrUseDefault = ( fileName, defaultLang, lang ) => {
  return getMarkdownContent( fileName, lang )
    .catch( err => getMarkdownContent( fileName, defaultLang ) )
}

Please notice the different behavior from coding documentation as HTML inside the React component, and as an external Markdown file, when the translation is incomplete. In the first case, if a string has been translated but another one has not (in the .po file), then the React component will end up displaying mixed languages. It’s all or nothing in the second case: either the documentation is fully translated, or it is not. 

Setting the documentation into the modal

By now, we can retrieve the documentation from the Markdown file. Let’s see how to display it in the modal.

We first wrap Gutenberg’s Modal component, to inject the content as HTML:

import { Modal } from '@wordpress/components';


const ContentModal = ( props ) => {
  const { content } = props;
  return (
    <Modal 
      { ...props }
    >
      <div
        dangerouslySetInnerHTML={ { __html: content } }
      />
    </Modal>
  );
};

Then we retrieve the content from the Markdown file, and pass it to the modal as a prop using a state hook called page. Dynamically loading content is an async operation, so we must also use an effect hook to perform a side effect in the component. We need to read the content from the Markdown file only once, so we pass an empty array as a second argument to useEffect (or the hook would keep getting triggered):

import { useState, useEffect } from '@wordpress/element';

const CacheControlContentModal = ( props ) => {
  const fileName = 'cache-control'
  const lang = window.graphqlApiCacheControl.userLang
  const defaultLang = 'en'


  const [ page, setPage ] = useState( [] );


  useEffect(() => {
    getMarkdownContentOrUseDefault( fileName, defaultLang, lang ).then( value => {
      setPage( value )
    });
  }, [] );


  return (
    <ContentModal
      { ...props }
      content={ page }
    />
  );
};

Let’s see it working. Please notice how the chunk containing the documentation is loaded lazily (i.e. it’s triggered when the block is edited):

Tadaaaaaaaa ?

Writing documentation may not be your favorite thing in the world, but making it easy to write and maintain can help take the pain out of it.

Using Markdown instead of pure HTML is certainly one way to do that. I hope the approach we just covered not only improves your workflow, but also gives you a nice enhancement for your WordPress users.