DEV Community

Usama Ejaz
Usama Ejaz

Posted on Originally published at justblogged.com

Post Dates Off by One Day After a WordPress Migration: Jekyll, Hugo and Eleventy Tested

A WordPress post at /2023/04/05/late-post/ shows up at /2023/04/06/late-post/ on the new static site. The timestamp is fine. A tool along the way read the UTC version of the publish time, or converted it into the build machine's zone, and the calendar day moved.

It only hits posts published close to midnight, so checking your three newest posts proves very little.

JustBlogged, the blog platform I run, keeps dates out of post URLs, so I'd never seen this break myself. On October 23, 2026 I pushed one WordPress post through the usual static route: a WXR export, the wordpress-export-to-markdown converter, then Jekyll, Hugo and Eleventy. Each one built with the machine clock set to Los Angeles, UTC and Karachi.

Short version... the day moved at the converter on its defaults, in Jekyll on any machine not set to the post's zone, and in Eleventy on any machine for date-only posts. Hugo kept the day it was given.

Which WordPress date your old URLs came from

WordPress stores two timestamps per post: post_date in the site's own time zone and post_date_gmt in UTC. Dated permalinks use the first one. get_permalink() splits post_date into year, month and day, and the source comment says to read it as local time whatever the server's PHP zone is.

The export carries the same moment three ways. For a post published at 11:30pm on April 5 on a site set to Los Angeles (UTC-7 in April):

<pubDate>Thu, 06 Apr 2023 06:30:00 +0000</pubDate>
<wp:post_date><![CDATA[2023-04-05 23:30:00]]></wp:post_date>
<wp:post_date_gmt><![CDATA[2023-04-06 06:30:00]]></wp:post_date_gmt>
Enter fullscreen mode Exit fullscreen mode

Only wp:post_date has the day that's in the live URL. Anything that reads pubDate or post_date_gmt and writes the date in UTC gives this post April 6.

The converter writes UTC dates unless you pass --timezone

wordpress-export-to-markdown 3.0.6 reads pubDate and applies --timezone, which defaults to utc. It also drops the time unless you pass --include-time. Same export, three runs:

Flags File written Front matter
Defaults 2023-04-06-late-post.md date: 2023-04-06
--timezone=America/Los_Angeles 2023-04-05-late-post.md date: 2023-04-05
--timezone=America/Los_Angeles --include-time=true 2023-04-05-late-post.md date: 2023-04-05T23:30:00.000-07:00

Use the zone from WordPress Settings > General as an IANA name (America/Los_Angeles, Asia/Karachi) and pass --include-time=true. The full timestamp with its offset is the only form all three generators below read back to the right day, and it keeps the time for your feed and datePublished markup.

Same date, three generators

I built two posts in each generator with a /:year/:month/:day/:slug/ style URL, using Jekyll 4.4.1, Hugo 0.168.0 and Eleventy 3.1.7. One post had date: 2023-04-05 23:30:00 -0700. The other had only a date (the 2023-04-05- filename prefix in Jekyll, date: 2023-04-05 in Hugo and Eleventy). Eleventy has no built-in date permalink, so its URL came from a directory data file using getFullYear(), getMonth() and getDate(). No config set a time zone. Each cell is the day that ended up in the URL.

Generator Post Built in Los Angeles Built in UTC Built in Karachi
Jekyll 4.4.1 23:30 -07:00 05 06 06
Jekyll 4.4.1 Date only 05 05 05
Hugo 0.168.0 23:30 -07:00 05 05 05
Hugo 0.168.0 Date only 05 05 05
Eleventy 3.1.7 23:30 -07:00 05 06 06
Eleventy 3.1.7 Date only 04 05 05

Jekyll keeps the moment but converts it to the build machine's zone before filling in the URL, so the offset in your front matter doesn't protect the day. A laptop in Los Angeles and a CI runner on UTC produce different URLs from the same repo. The Jekyll configuration docs say the default zone comes from the OS locally and from the server's setting on a remote host.

Hugo kept the written offset in every build. Its timeZone setting only decides how to read dates without an offset, and with it unset those came out as UTC. The written day stays in the URL, though templates that print the time will label it UTC.

Eleventy turns every front matter date into a UTC instant. A date with no time becomes midnight UTC, which is still the previous evening anywhere west of London, so local getters on a machine in the Americas put the post on the 4th. The Eleventy dates page lists this as a common pitfall for displaying dates. It hits permalinks built from those dates too.

Pinning each one

Jekyll, in _config.yml. With this line the post went back to /2023/04/05/ on the UTC build:

timezone: America/Los_Angeles
Enter fullscreen mode Exit fullscreen mode

Hugo, in hugo.toml, so offset-less dates are read the way WordPress meant them. My dated URLs were already right without it and stayed right with it:

timeZone = "America/Los_Angeles"
Enter fullscreen mode Exit fullscreen mode

Eleventy, build the URL in a named zone instead of the machine's. Eleventy depends on Luxon, but add it to your own package.json before requiring it. With the converter's full timestamps, this gave /2023/04/05/late-post/ on all three machines:

// posts/posts.11tydata.js
const { DateTime } = require("luxon");

module.exports = {
  permalink: (data) => {
    const d = DateTime.fromJSDate(data.page.date, { zone: "America/Los_Angeles" });
    return `/${d.toFormat("yyyy/LL/dd")}/${data.page.fileSlug}/`;
  },
};
Enter fullscreen mode Exit fullscreen mode

That file needs posts to have a time. On date-only posts it shifts midnight UTC back into Los Angeles time and moves them to the 4th again. If some posts have to stay date-only, build their URL with getUTCFullYear(), getUTCMonth() and getUTCDate() instead.

Listing the posts that moved

This compares the local day in the WordPress export with the day in the new site's sitemap and prints posts that moved or went missing. It assumes the new URLs keep a /YYYY/MM/DD/ segment and end with the WordPress slug.

# date_drift.py: python3 date_drift.py export.xml _site/sitemap.xml
import re, sys, xml.etree.ElementTree as ET

WP = "{http://wordpress.org/export/1.2/}"
DAY = re.compile(r"/(\d{4})/(\d{2})/(\d{2})/")

def old_days(wxr):
    days = {}
    for item in ET.parse(wxr).iter("item"):
        if item.findtext(WP + "post_type") != "post" or item.findtext(WP + "status") != "publish":
            continue
        local_day = item.findtext(WP + "post_date")[:10]   # what WordPress put in the URL
        days[item.findtext(WP + "post_name")] = local_day
    return days

def new_days(sitemap):
    days = {}
    for loc in re.findall(r"<loc>(.*?)</loc>", open(sitemap).read()):
        m = DAY.search(loc)
        if m:
            slug = loc.rstrip("/").rsplit("/", 1)[-1].removesuffix(".html")
            days[slug] = "-".join(m.groups())
    return days

old, new = old_days(sys.argv[1]), new_days(sys.argv[2])
for slug, day in sorted(old.items()):
    if slug not in new:
        print(f"missing  {slug}")
    elif new[slug] != day:
        print(f"moved    {slug}: {day} -> {new[slug]}")
Enter fullscreen mode Exit fullscreen mode

Against the unpinned Jekyll build on UTC it printed moved late-post: 2023-04-05 -> 2023-04-06. After adding timezone it printed nothing. It needs Python 3.9+ and only reads published posts, so drafts and pages don't add noise.

If a post can't go back to its old day, because the new URLs are already indexed or a template depends on the UTC date, redirect each old dated path to the new one wherever your host keeps redirects.

Jekyll and Eleventy can bring the shift back when you change build machines, with zero content changes, so I'd run the script again after any CI move. Picking a URL pattern without the date avoids the whole thing, but most people moving off WordPress don't get to make that choice

Top comments (0)