This site was migrated from WordPress to 11ty (Eleventy). Here's how I did it, including how to import and convert WordPress content that relies heavily on custom post types, custom taxonomies and custom meta fields.

Why 11ty?

My WordPress site had:

  • Regular blog posts
  • Custom post types: Projects and Experiments
    • Projects and Experiments child content: Project Logs and Experiment Logs. For example, a "Project" could have multiple "Project Logs" which appeared under the Project.
  • Custom taxonomies: Project Styles, Project Status, Tools, Project Types, Log Categories, Experiment Types
  • Lots of images with lightboxes
  • Code syntax highlighting
  • A lot of custom WordPress "meta" fields and boxes

I wanted something simpler:

  • Markdown files.
  • Git-based deployment.
  • No databases.

After some research I decided to go with 11ty (which I've never heard of before).

New Layout/Design

The first thing I did was creating a new design. I never liked using the WordPress' default theme, but I kept using it for almost 6 years, since I have customized it so much in every possible aspect.

The new design is inspired by "THE OLDSCHOOL PC FONT RESOURCE" which has a DOS-like feel to it, and it uses the "IBM VGA 9x16" as the main font.

To be honest there's still A LOT of room for improvement before I get the old IBM/DOS like and feel. But at least this is a start already.

Custom Post Types and Taxonomies in 11ty

The Structure

In WordPress, I had custom post types. In 11ty, I use the file system:

content/
├── posts/              # Regular blog posts
├── projects/           # Projects
│   └── project-slug/
│       ├── index.md    # Project main page
│       └── logs/       # Project logs
│           └── 2021-03-15-log-title.md
├── experiments/        # Experiments
│   └── experiment-slug/
│       ├── index.md
│       └── logs/
└── pages/              # Static pages

Custom Post Types as Collections

11ty uses collections to group content. They are defined in .eleventy.js:

eleventyConfig.addCollection("projects", function(collectionApi) {
  return collectionApi.getFilteredByGlob("content/projects/**/index.md")
    .filter(item => !item.data.draft)
    .sort((a, b) => dateB - dateA);
});

eleventyConfig.addCollection("projectLogs", function(collectionApi) {
  return collectionApi.getFilteredByGlob("content/projects/**/logs/*.md")
    .filter(item => !item.data.draft)
    .sort((a, b) => dateB - dateA);
});

Same pattern for experiments and experiment logs.

Taxonomies as Frontmatter

WordPress taxonomies became frontmatter fields:

---
title: "My Project"
type: "project"
projectTypes: ["Game Development"]
projectStyles: ["Retro", "Pixel Art"]
projectStatus: "Active"
tools: ["Godot", "Blender"]
---

Filters to query by taxonomy:

eleventyConfig.addFilter("filterByTaxonomy", function(collection, taxonomyName, value) {
  return collection.filter(item => {
    const taxonomy = item.data[taxonomyName];
    if (Array.isArray(taxonomy)) {
      return taxonomy.some(v => String(v).toLowerCase() === String(value).toLowerCase());
    }
    return String(taxonomy).toLowerCase() === String(value).toLowerCase();
  });
});

Dynamic Taxonomy Archives

Taxonomy archive pages are generated automatically:

eleventyConfig.addCollection("taxonomyArchives", function(collectionApi) {
  const allContent = collectionApi.getAll();
  const taxonomies = {
    projectTypes: new Set(),
    projectStyles: new Set(),
    // ... etc
  };
  
  // Extract all taxonomy values from content
  allContent.forEach(item => {
    if (item.data.projectTypes) {
      const values = Array.isArray(item.data.projectTypes) 
        ? item.data.projectTypes 
        : [item.data.projectTypes];
      values.forEach(v => taxonomies.projectTypes.add(v));
    }
  });
  
  // Generate archive entries
  const archives = [];
  Object.keys(taxonomies).forEach(taxonomyName => {
    taxonomies[taxonomyName].forEach(value => {
      archives.push({
        taxonomy: taxonomyName,
        category: value,
        url: `/${taxonomyUrlMap[taxonomyName]}/${slug(value)}/`
      });
    });
  });
  
  return archives;
});

Sitemap and RSS

The same sitemap structure that I had in WordPress was created with a Nunjuck template in 11ty. For example, here's a snippet from it:

  <section class="sitemap-section">
    <h2>Posts by Category</h2>
    {% set postsByCategory = collections.posts | groupBy("categories") %}
    {% for category in postsByCategory | keys | sort %}
    <h3>{{ category }}</h3>
    <ul class="sitemap-list">
      {% for post in postsByCategory[category] | sort(false, false, "data.date") %}
      <li>
        <a href="{{ post.url }}">{{ post.data.title }}</a>
        <time datetime="{{ post.data.date | dateISO }}"> ({{ post.data.date | dateDisplay("yyyy-MM-dd") }})</time>
      </li>
      {% endfor %}
    </ul>
    {% endfor %}
  </section>

WordPress Import Scripts

I created Node.js scripts to convert WordPress exports to markdown. This took way more time than I anticipated, because of the amount of custom info and relationships from my WordPress theme and data.

The Process

  1. Download WordPress data (I'm used the REST API, i.e. domain/wp-json/[post-type])
  2. Process each content type
  3. Convert and map all media files and all metadata
  4. Convert HTML to Markdown

Processing Projects

The script reads WordPress JSON exports and converts them:

// scripts/migrate/process-projects.js
const projects = readJSON('data/projects.json');
const projectLogs = readJSON('data/project-logs.json');
const taxonomies = readJSON('data/taxonomies.json');

projects.forEach(project => {
  // Extract taxonomies
  const projectTypes = getTaxonomyTerms(project, 'project_type', taxonomyLookup);
  const projectStyles = getTaxonomyTerms(project, 'project_style', taxonomyLookup);
  
  // Convert HTML content to Markdown
  const markdown = htmlToMarkdown(project.content.rendered);
  
  // Get featured image
  const featuredImage = getMediaFullUrl(project.featured_media, mediaLookup);
  
  // Create frontmatter
  const frontmatter = {
    title: project.title.rendered,
    date: formatDate(project.date),
    type: "project",
    projectTypes: projectTypes,
    projectStyles: projectStyles,
    featuredImage: convertWordPressUrlToLocalPath(featuredImage)
  };
  
  // Write markdown file
  writeFile(outputPath, frontmatter, markdown);
});

Handling Project Logs

Project logs needed to link to their parent project:

// Map logs to projects
const logsByProject = {};
projectLogs.forEach(log => {
  const parentProjectId = log.meta.project_log_parent_project;
  if (parentProjectId) {
    if (!logsByProject[parentProjectId]) {
      logsByProject[parentProjectId] = [];
    }
    logsByProject[parentProjectId].push(log);
  }
});

// When processing a project, also process its logs
projects.forEach(project => {
  const projectSlug = generateSlug(project.title.rendered);
  const logs = logsByProject[project.id] || [];
  
  logs.forEach(log => {
    const logPath = path.join(projectDir, 'logs', `${formatDateForFilename(log.date)}-${generateSlug(log.title.rendered)}.md`);
    // Process and write log file
  });
});

HTML to Markdown Conversion

WordPress content is HTML. I used turndown with custom rules and the imageGallery callback:

const turndown = new TurndownService({
  headingStyle: 'atx',
  codeBlockStyle: 'fenced'
});

// Custom rule for WordPress galleries
turndown.addRule('wpGallery', {
  filter: function(node) {
    return node.nodeName === 'UL' && node.classList.contains('wp-block-gallery');
  },
  replacement: function(content, node) {
    const images = Array.from(node.querySelectorAll('img'));
    const imageArray = images.map(img => ({
      src: img.src,
      alt: img.alt || ''
    }));
    return `{` + `% imageGallery ${JSON.stringify(imageArray)} %` + `}`;
  }
});

Media Migration

Media was downloaded from /wp-upload/uploads and moved to /media/wp-content/. Old links are preserved via a rule in the nginx file to permanently redirect them to the new permalink format.

Then updated all image references in markdown from WordPress URLs to local paths:

function convertWordPressUrlToLocalPath(url) {
  if (!url) return url;
  return url.replace(
    /https?:\/\/alfredbaudisch\.com\/wp-content\/uploads\//g,
    '/media/wp-content/'
  );
}

Meta Fields

WordPress custom fields became frontmatter:

const meta = project.meta || {};
const processImage = getProcessImageUrl(meta.process_image, mediaLookup);
const links = getLinksFromMeta(meta);

const frontmatter = {
  // ... other fields
  processImage: convertWordPressUrlToLocalPath(processImage),
  links: links
};

For example, this is the frontmatter for one of my old experiment posts. You can see how taxonomies and meta fields are used (including processImage and links, these were all automatically converted from the WordPress post):

---
layout: "layouts/experiment.njk"
title: "154: Re-learning Blender 3.0 Geometry Nodes"
date: "2022-01-13T23:44:08.000Z"
updated: "2022-01-14T00:00:00.000Z"
type: "experiment"
tags: ["geometry nodes"]
experimentTypes: ["3D Art"]
tools: ["Blender"]
featuredImage: "/media/wp-content/2022/01/154-blender-geometry-nodes-learning12.gif"
featuredImageThumb: "/media/wp-content/2022/01/154-blender-geometry-nodes-learning12-768x421.gif"
featuredImageSmall: "/media/wp-content/2022/01/154-blender-geometry-nodes-learning12-300x165.gif"
processImage: "/media/wp-content/2022/01/155-process-blender1.jpg"
projectStyles: ["Procedural"]
links:
  - name: "Easy Geometry Nodes PLANTS - Blender 3.0"
    url: "https://www.youtube.com/watch?v=VTkUVtWbjoE"
  - name: "Blender 3.0 New Geometry Nodes Tutorial"
    url: "https://www.youtube.com/watch?v=UqRVxosrnGc"
  - name: "Geometry Nodes Blender 3.0 Tutorial - Make A Trash Dump Fast"
    url: "https://www.youtube.com/watch?v=M14iZxkUUAQ"
  - name: "What are Fields? - Geometry Nodes 101"
    url: "https://www.youtube.com/watch?v=8FCHcbpnFss"
  - name: "Create and Animate a Procedural Castle in Blender"
    url: "https://skl.sh/3Fqlcqw"
---

Deployment

Build Process

The build script:

  1. Bundles JavaScript (esbuild)
  2. Copies CSS
  3. Runs 11ty to generate static site
# package.json
"build": "rm -rf _site && NODE_ENV=production npm run build:js && NODE_ENV=production npm run build:css && NODE_ENV=production eleventy"

Deployment Script

Two deployment methods:

Local deployment

  • scripts/deploy/deploy.sh
  • Builds the site
  • Uses rsync to sync _site/ to VPS (since 2020 I use a $5 DigitalOcean VPS)
  • Easy deployment by calling npm run deploy locally

rsync:

rsync -avz --delete \
  -e "ssh -i $SSH_KEY" \
  "$BUILD_DIR/" "$VPS_USER@$VPS_HOST:$DEPLOY_DIR/_site/"

These are the package.json scripts:

"scripts": {
    "build": "rm -rf _site && NODE_ENV=production npm run build:js && NODE_ENV=production npm run build:css && NODE_ENV=production eleventy",
    "build:js": "node scripts/build-js.js --production",
    "build:js:dev": "node scripts/build-js.js",
    "build:css": "node scripts/build-css.js --production",
    "build:css:dev": "node scripts/build-css.js",
    "serve": "eleventy --serve",
    "dev": "rm -rf _site && NODE_ENV=development npm run build:js:dev && NODE_ENV=development npm run build:css:dev && NODE_ENV=development eleventy --serve --watch",
    "deploy": "bash scripts/deploy/deploy.sh"
}

GitHub Actions

  • .github/workflows/deploy.yml
name: Deploy to VPS

on:
  push:
    branches: [ master ]

jobs:
  deploy:
    runs-on: ubuntu-latest
    
    steps:
    - name: Checkout code
      uses: actions/checkout@v3
      
    - name: Setup Node.js
      uses: actions/setup-node@v3
      with:
        node-version: '18'
        cache: 'npm'
    
    - name: Install dependencies
      run: npm ci
    
    - name: Build site
      run: npm run build
    
    - name: Sync files to VPS using rsync
      run: |
        rsync -avz --delete --no-owner --no-group \
          -e "ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -i $" \
          _site/ $@$:/var/www/alfredbaudisch.com/_site/