Internationalising dates with JavaScript

Articles come with a new metadata to display: their date. Without it, no way to know when their content was written and if it can still be applied.

Fortunately, Metalsmith already understand that a date property set in a file's front-matter needs to be transformed in a JavaScript Date object. This leaves only the formatting to do.

Not just turning the object into a human understandable String, but handling the correct rendering depending on the language.

JavaScript's internationalisation API

Natively, the Date object offers a toLocaleString method to handle just our situation. It provides multiple options for:

My preferred format for displaying date is to have the days on 2 digits (consistent length), the months as abbreviated text (lifts ambiguity between UK and US date) and 2 digits or full year (depending on space available). To achieve this with the API, it becomes:

const date = new Date(2020,07,04);
date.toLocaleString('en-gb', {year: 'numeric', month: 'short', day:'2-digit'}) // 04 Aug 2020
date.toLocaleString('fr', {year: 'numeric', month: 'short', day:'2-digit'}) // 04 août 2020

It's a bit more verbose than the DD MMM YYYY kind of strings used by a lot of formatting libraries. It makes it up in clarity, in my opinion (is 'M' the month on a single digit? or in short text?).

To format multiple dates the same way, it'll be more convenient (and performant) to build an Intl.DateTimeFormat and reuse it for each date. Among all the internationalisation goodies inside the Intl object, it's the one responsible for… well… formating dates. It takes the same arguments as toLocaleString (or maybe toLocaleString takes the same as DateTimeFormat? feels likely): locale, then format options.

const formatter = new Intl.DateTimeFormat('fr', {year: 'numeric', month: 'short', day:'2-digit'});
const date = new Date(2020,07,04);
formatter.format(date); // 04 août 2020

Sounds perfect!

Internationalisation with Node

Those internationalisation API is supported in Node. They're also in modern browsers (and IE11), have a thought before sending kilobytes to users for formatting dates.

However, running a version of Node lower than 13 (like the 12.x versions with Long Term Support at the time of this writing), toLocaleString() or format() might keep returning an American English date for a any locale.

Node delegates internationalisation to ICU,a C/C++ library. Before version 13, its default build embarked only a "small" part of the data for ICU, assuming most users won't need the full version.

The simplest workaround is to upgrade Node to a newer version (like 14.x, which will get Long Term Support).

Not possible? All is not lost, the full ICU data can be installed through the full-icu NPM package. From there, you can give Node a hint of where it is via the --icu-data-dir flag or the NODE_ICU_DATA variable. Either can be conveniently set in an NPM script for starting the project, making it very portable:

{
  "scripts": {
    "start": "node -icu-data-dir=node_modules/full-icu src/index.js",
    "dev": "nodemon --exec npm start"
  },
  "dependencies":{
    "full-icu": "^1.3.1"
  }
}

As a last resort, it's always possible to build Node from source with the appropriate flag: --with-intl=full-icu --download=all (also works with NVM). This implies ensuring every machine that'll run the code has Node built the right way: each developer's device, each server, each continuous integrations environment… Feels like a recipe for headaches.

Metalsmith and the internationalisation API

Now let's use this internationalisation API to format the date of each post. As there'll be multiple dates to format in one run, we'll create one Intl.DateTimeFormat for each language, inside the src/index.js file:

//...
const FORMATTERS = {
  en: new Intl.DateTimeFormat('en-gb', {
    year: 'numeric',
    month: 'short',
    day: '2-digit'
  }),
  fr: new Intl.DateTimeFormat('fr', {
    year: 'numeric',
    month: 'short',
    day: '2-digit'
  })
};

metalsmith(process.cwd())
//...

We'll then need to have them reach the templates through the site's metadata. Pug unfortunately broke when calling their format method from the templates. Instead, we can provide the templates functions wrapping that call and things worked just fine:

//...
.metadata({
  //...
  dateFormats: {
    en: date => FORMATTERS.en.format(date),
    fr: date => FORMATTERS.fr.format(date)
  }
  //...
})
//...

From there, we can use those functions in the layout/post.pug template to display the date right after the title. The <time> tag will provide some semantics to indicate that it's some time data and provide an ISO representation of the date via its datetime attribute.

//- ...
time.no-margin-top(datetime=date.toISOString())
  = get(dateFormats,i18n.language)(date)
//- ...

The date is now nicely set for each post, in the right language. That makes each single post page ready (for now) and we can move on to displaying the listing of posts in the next article.