How I Converted My Website from Vanilla to Eleventy
A vanilla website has to converting to a static site generator when it expands, and how straightforward is Eleventy, the simpler static site generator?
Posted on March 13, 2026 | Tags: CodingWeb DevEleventyJavaScript
I had promised to write a blogpost detailing what the title stated, wanting to share the knowledge in case someone finds this useful. There are many posts on creating an Eleventy site from scratch, but what about an intuitive one on converting a website coded with vanilla HTML, CSS, and JavaScript? However, I kept delaying it to focus on website improvements and creative works such as Silver Scripts. Well, since the blog has now launched, it is time for me to write it.
My ADHD-addled memory is forgetful, but fortunately, the website is deployed via a Git repository, so I can go to GitHub and look back through previous commits, with the messages and all.
Let's go back to October 2025. Be prepared; this will be a long post!
Disclaimer about AI: Yes, I did use AI to assist with my code (but not fully generate it, because "vibe coding" is not real coding). I want to be honest about my process; I thought it was okay for solving time-consuming problems quickly, since I wasn't too familiar with Eleventy, and coding is based on objective logic rather than subjective creativity. I did read docs and posts beforehand, and I did manually verify the code to see if it makes sense and try to learn from the code as the result. However, I am now trying to cut back on AI-assisted coding to grow as a programmer. I will stress that no AI-generated literature or media were used in the process.
The Reason Why I Converted
First, why?
I was a web development newbie when I first started the website. It was 2022; I was a high school senior who knew programming mostly from Scratch and some Python and Java for real programming. However, I also knew some HTML and CSS, mainly thanks to writing pages on Fandom wikis. So, I did what a web dev newbie would do: Create a webpage with HTML and CSS and eventually other webpages in the same manner, linking to them on the homepage.
My website was vanilla, meaning there were no frills and quills such as the jQuery library and Bootstrap framework. Just standard HTML, CSS, and JavaScript that you could just type into a file and the browser could run it.
More of the website history is detailed on "The History of This Website".
As my website continued to expand, I kept the header and footer consistent by embedding JavaScript scripts that would write the HTML code, which I learned from the vanilla webcomic site template Rarebit. However, that gimmick couldn't be used for the HTML head element, which contains the webpage metadata. That meant situations such as that when I wanted to preload fonts for performance optimization, I had to add such code to each webpage.
Also, I had a manually written sitemap so Google could process my website better than with the sitemap generated by the Netlify plugin. Cue frequent moments of forgetting to update the "lastUpdated" property of webpages.
I needed to implement a static site generator.
First Steps with Eleventy
I had heard of static site generators such as Astro and Hugo before, but when I searched for the easiest one to use, the consensus was Eleventy. That's why I decided to use it.
With my pre-existing knowledge of npm (I worked on a web-based app in college), I installed Eleventy and its dependencies into the Git repository. Then, I created a layout file so I could reuse the website layout, with the header, background, metadata, and all, automatically, just by specifying the file's name as the layout field in the webpages' front matter. (The layout file's format is HTML, which meant the templating language was Liquid.)
Within the layout file, I could embed template fields so the webpages' front matter values could be filled there in the final product. For example, to display the webpage's title, I put How I Converted My Website from Vanilla to Eleventy - Wolf with a Blog in the layout file's body tag. After the site is generated, each webpage has its title displayed at that field's place.
With the power of the templating language, I could have certain content and template fields be rendered conditionally, based on whether certain front matter fields equaled something or not. For instance, I could have the default title at the homepage, and the title end with " - ❤︎PrincessPandaLover" on other pages.
{% if title %}
<title>{{ title }} - ❤︎PrincessPandaLover</title>
{% else %}
<title>❤︎PrincessPandaLover - Official website</title>
{% endif %}
Eleventy does not merely transfer files such as images and CSS stylesheets to the generated site on its own. This has to be done by implementing passthrough copies in the configuration file.
// Import pre-existing resources to build
eleventyConfig.addPassthroughCopy("images");
eleventyConfig.addPassthroughCopy("scripts");
eleventyConfig.addPassthroughCopy("fonts");
eleventyConfig.addPassthroughCopy("_headers");
eleventyConfig.addPassthroughCopy("robots.txt");
eleventyConfig.addPassthroughCopy("google34125cee924c333d.html");
(Yes, I used to lump CSS stylesheets with JavaScript files in a single "scripts" folder. Don't worry, CSS stylesheets are now in a "styles" folder.)
I disliked Eleventy's standard feature of trailing slashes at end of URLs. Those felt ugly to me, so I used a relatively complicated script by GitHub Copilot in the configuration file, at first. I eventually found a much simpler script from the Eleventy documentation:
eleventyConfig.addGlobalData("permalink", () => {
return (data) =>
`${data.page.filePathStem}.${data.page.outputFileExtension}`;
});
(If you're wondering how the extension of HTML files is handled, I deploy on Netlify, which has the option for "Pretty URLs", which removes file extensions from pages. Speaking of, I'm also using the Netlify CLI package for local development with the Netlify environment.)
Also in the configuration file, I made a filter that takes a JavaScript date object and renders the date in the format of "January 1, 2026", so dates on articles could be displayed without typos. The Luxon library, which is included with Eleventy, was strongly useful for this.
eleventyConfig.addFilter("postDate", (dateObj) => {
return DateTime.fromJSDate(dateObj).toLocaleString(DateTime.DATE_FULL);
});
If you just want to reuse your website's layout, your journey ends here, but if you want to optimize your images for better performance and PageSpeed Insights score, maintain consistency for a blog, and so, read on!
(I changed the HTML file's templating language to Nunjucks with the following code in the configuration file, after the export default function (eleventyConfig) function.)
export const config = {
htmlTemplateEngine: "njk",
};
Automatic image optimization
Slowing down the webpage with large images is no fun, especially when the user is on cellular. *sigh* Even if you shrink the image's display size on the page, the browser will still load the full image, including its full size. That's why it's recommended to load images at different sizes depending on the browser's viewport resolution.
Here's how I optimized images before the Eleventy conversion: With Irfanview, I converted images to WEBP (more optimized than PNG), resized them to widths of 768px (mobile), 1280px (tablet), and 1980px (desktop) (if they were wider than each, respectively), and sorted them in directories based on their widths. I used the <picture> element to set the image file to use as the <img> element's source, based on whether the viewport resolution was thinner than certain widths, and whether the browser supports the WEBP format. (I also had images that didn't appear at the webpage's top lazy load, so they would only load once they visually appeared on the screen.)
<picture>
<source media="(max-width: 768px)" srcset="/images/tpt2/webp/768/Wolf-prop-concept-art-1.webp">
<source media="(max-width: 1280px)" srcset="/images/tpt2/webp/1280/Wolf-prop-concept-art-1.webp">
<source media="(max-width: 1920px)" srcset="/images/tpt2/webp/1920/Wolf-prop-concept-art-1.webp">
<source srcset="/images/tpt2/webp/Wolf-prop-concept-art-1.webp" type="image/webp" />
<img src="/images/tpt2/Wolf-prop-concept-art-1.png" width="2019" height="2671" loading="lazy" alt="Concept art in notebook and figuring out anatomy of the stylized wolf"/>
</picture>
For thumbnail images, I resized the images to double their display widths and organized them in a folder for thumbnails. (As an explanation for why double the width, computer displays these days are 2x, which means that the UI's resolution is double the screen's resolution, so doubling is necessary to prevent the image from showing as blurry.)
Of course, manually batch converting images and specifying their <picture> elements get tedious (I remember being so burnt out adding the <picture> elements, but I was also playing Eatventure at the same time, so whatever), so there has to be an automated way. Fortunately, Eleventy provides one through their official Image plugin. In fact, there are several ways.
My first way was with image shortcodes. So, instead of an <img> element, you use a "shortcode" that specifies the image properties, such as source, alternative text, height, width, loading type, and class. (This was only for raster images, as vector images, aka the SVG files, are already efficient.)
{% image "images/tpt2/Strangely-colored-wolf-head.png", "Me with head of wolf prop with parts colored red, cerulean, and green", null, null, false %}
In the configuration file, with the help of GitHub Copilot, I wrote an extensive script for the image shortcode. It first obtained the original metadata and subsequently width of the image and determined the widths and viewport size options to use based on the width. It then set the file formats to be used as the original and WEBP and constructed the source's path based on its converted file format, resized width, and original directories. It lastly added image element attributes of the alternative text, different sizes, the loading type (with "lazy" as the default), an inline style for max-width if the shortcode specified the width, and image's class if it is specified, before returning the final Image element.
eleventyConfig.addShortcode("image", async function (src, alt, width=null, height=null, lazy=true, classPara=null) {
// Get original image metadata
let ogMetadata = await Image(src, {
widths: ["auto"],
formats: ["auto"],
outputDir: "./_site/images/",
urlPath: "/images/",
dryRun: true
})
// Get original width
let ogWidth = null;
const formatKeys = Object.keys(ogMetadata);
if (formatKeys.length > 0) {
ogWidth = ogMetadata[formatKeys[0]][0].width;
}
// See if specificed width is different from OG
let widthsOption = ["auto"];
let sizeOptions = "100vw";
if (ogWidth > 1920) {
widthsOption = [768, 1280, 1920, "auto"];
sizeOptions = "(max-width: 768px) 768px, (max-width: 1280px) 1280px, (max-width: 1920px) 1920px, 100vw";
} else if (ogWidth > 1280) {
widthsOption = [768, 1280, "auto"];
sizeOptions = "(max-width: 768px) 768px, (max-width: 1280px) 1280px, 100vw";
} else if (ogWidth > 768) {
widthsOption = [768, "auto"];
sizeOptions = "(max-width: 768px) 768px, 100vw";
}
let metadata = await Image(src, {
widths: widthsOption,
formats: ["webp", "auto"],
outputDir: "./_site/images/",
urlPath: "/images/",
filenameFormat: function (id, src, width, format, options) {
// Get filename
const extension = path.extname(src);
const name = path.basename(src, extension);
// Get subdirectory's name
const srcPath = src.replace(/\\/g, '/');
const imagesPathIndex = srcPath.indexOf('images/');
let subDirName = '';
if (imagesPathIndex !== -1) {
const pathAfterImages = srcPath.substring(imagesPathIndex + 'images/'.length);
const subDirPath = path.dirname(pathAfterImages);
if (subDirPath && subDirPath !== '.') {
subDirName = subDirPath + '/';
}
}
// Determine if image was resized
const ogWidth = options.sourceWidth;
const wasResized = width && width !== ogWidth && sizeOptions.includes(width);
if (format === 'webp') {
if (wasResized) {
return `${subDirName}webp/${width}/${name}.${format}`;
}
return `${subDirName}webp/${name}.${format}`;
}
return `${subDirName}${name}.${format}`;
}
});
let imageAttributes = {
alt,
sizes: sizeOptions,
loading: lazy ? "lazy" : "eager",
decoding: "async"
};
// Add inline style for resizing in webpage
if (width !== null || height !== null) {
let styles = [];
if (width != null) styles.push(`max-width: ${width}px`);
if (height != null) styles.push(`max-height: ${height}px`);
imageAttributes.style = styles.join('; ');
}
// Add image's style class if there's one
if (classPara) {
imageAttributes.class = classPara;
}
return Image.generateHTML(metadata, imageAttributes);
})
This would generate files for different versions of the image automatically.
The shortcode for thumbnail images set the sizes attribute of the image element to be auto and the width to be double of the original width (if the original image wasn't already wide as that double). The shortcode for thumbnail animated GIF images is the same but sets the Sharp option that the image is animated.
All was set for my image optimization process. Then, I learned from this blogpost by Bob Monsour (warning: mild swearing in URL) that the plugin's HTML Transform feature is much faster for performance than shortcodes. (Indeed, my website's buildtime took more than several seconds.) For the sake of my free Netlify build minutes, I had to redo the whole script.
The new script is much less complicated. It practically takes all the viewport sizes as the size and width options and falls back on the largest image for the viewport size. HTML Transform allows me to use the <img> elements again (albeit with inline styles for widths set on the webpage)! (SVG files were ignored with the eleventy:ignore attribute). However, sorting converted images into directories was impossible, so I had their width values added to their set filenames instead.
eleventyConfig.addPlugin(Image.eleventyImageTransformPlugin, {
formats: ["webp", "auto"],
outputDir: "_site/",
urlPath: "/",
widths: [768, 1280, 1920, "auto"],
sharpOptions: {
animated: true,
},
filenameFormat: function (id, src, width, format, options) {
const extension = path.extname(src);
const name = path.basename(src, extension);
const dir = path.dirname(src);
return `${dir}/${name}-${width}.${format}`;
},
htmlOptions: {
imgAttributes: {
loading: "lazy",
decoding: "async",
sizes: "(max-width: 768px) 100vw, (max-width: 1280px) 100vw, (max-width: 1920px) 100vw, 100vw"
},
pictureAttributes: {},
fallback: "largest"
}
});
(I had the original images copied over by adding passthrough copies for the image directories, so users can still access them by removing the width value and changing the file format to the original in the URL bar.)
To further reduce the buildtime on Netlify, I used the Netlify plugin for cache so that files are saved between builds and can easily be retrieved during the process.
To preserve parts of the website not to be affected by Eleventy, such as with the HTML transform plugin, I first add the path of such directory to the ".eleventyignore" file, which exempts listed files and directories from being processed by Eleventy, and then add a passthrough copy for the directory.
Collections and pagination: Creating pages from multiple files
I have My Theme Park Tycoon 2 Buildlog, which was intended to be in a rather simple blog format. There would be a page for each month, with each page having dated posts for its respective month. While less cluttered than pages for each post, I still had to manually edit the pages to change the blog's layout, as well as sift through lots of code to edit a single post.
Eleventy's collections feature allows for generating webpages with data from groups of webpages. Collections are formed by using the same tag for the webpages. The template file can determine the format of the webpages based on the collection.
I created an individual HTML file for each post, each having the permalink field as false so Eleventy wouldn't generate webpages from them, and its day in the title field for the ease of anchor links. I first tried the straightforward approach: Tagging posts both with their blog tag (tpt2) and a specific blog tag for their month (such as tpt2July2024). I used a JSON file, which applies data to all pages in the directory, to add the general tag to all posts, as well as a special layout file for "fun blogs" like the Theme Park Tycoon 2 Buildlog. For each month, I created a template file which would generate and format the posts from the respective collection.
The JSON file:
{
"layout": "more-layouts/fun-blog.html",
"blogTitle": "My Theme Park Tycoon 2 Buildlog",
"tags": "tpt2"
}
A template file for the July 2024 collection:
---
month: July
year: 2024
description: Entries of My Theme Park Tycoon 2 Buildlog for the month of July 2024.
ogImage: tpt2/webp/Strangely-colored-wolf-head.webp
ogDescription: Entries of My Theme Park Tycoon 2 Buildlog for the month of July 2024. The Buildlog is where I publicly record my process of creating custom builds in the Roblox building simulator game Theme Park Tycoon 2.
---
{% for entry in collections.tpt2July2024 | reverse %}
<article>
<h3 id="{{ entry.data.title }}">{{ entry.data.date | postDate }} <a href="#{{ entry.data.title }}" class="white-link" title="Direct link to this entry"><i class="fa-solid fa-link"></i></a>{% endcall %}
<div class="text-box">
{{ entry.content }}
</div>
</article>
{% endfor %}
The straightforward approach is enough if you want a single webpage for each blogpost. However, I didn't, and I wanted all the months' pages to be consistent.
With the help of GitHub Copilot, I wrote this complicated script that for each of the "fun blog" collections, it is replaced with a new collection in which the entries are reversed (because Eleventy's default order is oldest first) and the new collection is an Object that has an array of posts for each month (the month being the key and the array being the value), and then the Object is turned into an array of Objects with "monthKey" and "entries" keys for easier processing. (acc[monthKey] ??= [] automatically creates the array if it doesn't exist.)
Then, there are filters for processing the month keys, in a format like "2024-05", to obtain the years and month names. While the year filter just splits the string, the month filter adds the value for the first day (e.g. "2024-05-01") and converts the date to the month name.
The Luxon library provided lots of help for the date and time operations.
const funBlogCollectionTags = ["tpt2Log"];
funBlogCollectionTags.forEach(tag => {
eleventyConfig.addCollection(tag, function(collectionApi) {
const grouped = collectionApi
.getFilteredByTag(tag)
.reverse()
.reduce((acc, entry) => {
const monthKey = DateTime.fromJSDate(entry.date, { zone: 'utc' }).toFormat('yyyy-MM');
(acc[monthKey] ??= []).push(entry);
return acc;
}, {});
return Object.entries(grouped).map(([monthKey, entries]) => ({
monthKey,
entries
}));
});
});
eleventyConfig.addFilter("getMonthName", (monthKey) => {
if (!monthKey) return '';
const dateStr = String(monthKey) + '-01';
const dt = DateTime.fromISO(dateStr);
if (!dt.isValid) {
console.error('Invalid date for monthKey:', monthKey, 'monthKey\'s type:', typeof monthKey);
return '';
}
return dt.toFormat('MMMM');
});
eleventyConfig.addFilter("getYear", (monthKey) => {
if (!monthKey) return '';
return String(monthKey).split('-')[0];
});
Now, on the template page for the Buildlog, I can title each month's page with the month's name and year and lay down all its entries with rather short code.
---
pagination:
data: collections.tpt2Log
size: 1
alias: monthlyData
permalink: /tpt2/{{ monthlyData.monthKey | getMonthName }}-{{ monthlyData.monthKey | getYear }}.html
eleventyExcludeFromCollections: ["tpt2Log"]
eleventyComputed:
layout: layouts/main.html
title: "{{ monthlyData.monthKey | getMonthName }} {{ monthlyData.monthKey | getYear }} - My Theme Park Tycoon 2 Buildlog"
...
---
<h1>My Theme Park Tycoon 2 Buildlog - {{ monthlyData.monthKey | getMonthName }} {{ monthlyData.monthKey | getYear }}</h1>
...
{% for entry in monthlyData.entries %}
<article>
<h3 id="{{ entry.data.title }}">{{ entry.data.date | postDate }} <a href="#{{ entry.data.title }}" class="white-link" title="Direct link to this entry"><i class="fa-solid fa-link"></i></a>{% endcall %}
<div class="text-box">
{{ entry.content }}
</div>
</article>
{% endfor %}
What's cool about pagination is that you can get the previous and next pages, and if they exist, you can have links to them added.
{% set previousMonth = pagination.href.next %} {# Because the entries are reversed #}
{% set nextMonth = pagination.href.previous %}
...
{% if previousMonth %}<a class="button" href="{{ previousMonth }}">Previous</a>
...
{% if nextMonth %} | <a class="button" href="{{ nextMonth }}">Next</a>{% endif %}
What's also cool about pagination is that you can get the array of pages in the collection yourself and iterate over it, such as filling up the dropdown (<select>) element as in below.
<select id="article-select-0">
{% for page in pagination.pages %}
{% set pageMonth = page.monthKey | getMonthName %}
{% set pageYear = page.monthKey | getYear %}
<option value="{{ pageMonth }}-{{ pageYear }}.html">{{ pageMonth }} {{ pageYear }}</option>
{% endfor %}
</select>
The pagination feature also allowed me to later add a table of contents feature to each page, containing links to the posts below.
<nav class="text-box list-only">
<ul>
{% for entry in monthlyData.entries %}
<li><a href="#{{ entry.data.title }}">{{ entry.data.date | postDate }}</a></li>
{% endfor %}
</ul>
</nav>
Sitemap generation
Again, when the website was vanilla, I had a manually written sitemap so Google could read my website better. Priority and changefreq values and last modified dates, oh my! Because I kept forgetting to update webpages' last modified dates, a static site generator needed to step in.
This guide by Duncan McDougall helped me set up a template file that generates the sitemap by going through the default "all" collection, which contains all pages except for the ones manually excluded. (For paginated pages, you set addAllPagesToCollections to true under pagination in the front matter.)
---
permalink: /sitemap.xml
eleventyExcludeFromCollections: true
---
<?xml version="1.0" encoding="UTF-8"?>
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
{% for page in collections.all %}
{% if not page.data.noSitemap %}
<url>
<loc>https://princesspandalover.com{{ page.url | lower | stripExtension }}</loc>
<lastmod>{{ page | lastModifiedDate | sitemapDate }}</lastmod>
<changefreq>{{ page.data.changefreq | default("monthly") }}</changefreq>
<priority>{{ page.data.priority | default(0.8) }}</priority>
</url>
{% endif %}
{% endfor %}
</urlset>
Yep, I had to add custom changefreq and priority field to pages on the sitemap, as well as the noSitemap field (set to true) to pages to be excluded. (changefreq and priority values may not matter to Google, but I included them for convenience.)
Last modified dates are trickier to deal with. Eleventy dates reflect when the page was created, not modified. Yeah, I could add a custom field for that, but I would have to manually update every time and I'm really forgetful.
I had Gemini 3.0 Pro via GitHub Copilot generate some complicated scripts. The first script executes the git log command to obtain the history of the last 500 commits on Git (so buildtime isn't slowed down) with ISO-format timestamps and filenames changed in each commit (and max buffer of 10 MB). Then for each date, for each changed file, it adds the pairing of the path to the file (in the source code) and the date to the cache map. Older commits with the same file are skipped to ensure only the newest modified date is assigned to the file. If this script fails, it logs an error message instead of crashing the entire process. (Yikes.)
import { execSync } from "node:child_process";
...
// Cache for Git dates to speed up build
const gitDateCache = new Map();
// Pre-load Git history to avoid spawning process for every file (Gemini 3 Pro)
try {
const output = execSync('git log --name-only --format="GIT_DATE:%cI" --max-count=500', {
encoding: 'utf-8',
maxBuffer: 10 * 1024 * 1024 // 10MB buffer
});
let currentDate = null;
output.split(/\r?\n/).forEach(line => {
const trimmed = line.trim();
if (!trimmed) return;
if (trimmed.startsWith('GIT_DATE:')) {
currentDate = new Date(trimmed.slice(9));
} else if (currentDate) {
// Git outputs paths like "src/pages/about.md"
// Eleventy uses "./src/pages/about.md"
const key = "./" + trimmed;
if (!gitDateCache.has(key)) {
gitDateCache.set(key, currentDate);
}
}
});
} catch (e) {
console.warn("Git log failed, falling back to individual checks:", e.message);
}
This blogpost by Brian Cantoni, which is for the same last modified date issue, was useful for verifying some of the script.
The second script adds a filter in which the page's path in the source code is obtained and the cache map is sifted through for the path and its date. If they cannot be found, such as the path pointing to an entirely new file, it gets its last modified time from the filesystem. If the page is the result of pagination, such as a My Theme Park Tycoon 2 Buildlog month page, it goes through all the pages used for the pagination and gets the most recent date from it.
import fs from "node:fs";
...
// Get last modified date for sitemap via Git (Gemini 3 Pro)
eleventyConfig.addFilter("lastModifiedDate", (page) => {
const inputPath = page.inputPath;
// Helper to get date for a specific path
const getDateForPath = (path) => {
if (gitDateCache.has(path)) {
return gitDateCache.get(path);
}
// Fallback: try fs stats if not in git log (e.g. new file)
try {
const stats = fs.statSync(path);
return stats.mtime;
} catch (e) {
return null;
}
};
let latestDate = getDateForPath(inputPath);
// If this is a paginated page, check the items on this specific page
if (page.data && page.data.pagination && page.data.pagination.items) {
for (const item of page.data.pagination.items) {
if (item.inputPath) {
const itemDate = getDateForPath(item.inputPath);
if (itemDate && (!latestDate || itemDate > latestDate)) {
latestDate = itemDate;
}
}
}
}
return latestDate || new Date();
});
Reusable webpage components
By components, I do not mean WebC, but by the general definition of components. Like buttons. Like buttons that look like hearts because a heart SVG image is behind the label.

This was something I implemented after the main migration to Eleventy was complete in November 2025. Trying to keep the same copied-and-pasted HTML code consistent is not convenient, but fortunately, the templating language Nunjucks provides ways to reuse components.
As I learned from this blogpost by W. Evan Sheehan, you use includes for components that don't require parameters and macros for components that do. I used macros, as I need to change up the label and link every time.
In the "_includes" directory, I created a Nunjucks file in which for each component, I wrapped its HTML content within {% macro %} and {% endmacro %} tags and specified the parameters to be filled in within the content.
{% macro heartButton(href, text, longer) %}
<a href="{{ href }}"><div class="heart-button-container">
<img eleventy:ignore class="heart-button" src="images/heart-button.svg" width=120 height=110 fetchpriority="high" alt="Light magenta heart symbol with magenta outline" aria-hidden="true">
<!--<div class="heart-image">
<img src="" height=65 >
</div>-->
<div class="heart-button-text {% if longer %}longer{% endif %}">
{{ text }}
</div>
</div></a>
{% endmacro %}
(Yes, there were supposed to be little custom images to be plastered on the heart buttons, but I didn't get around to drawing them.)
In the HTML file for the homepage, I can import the Nunjucks file with the components, with a short alias for ease, and repeatedly call the macros with their parameters.
{% import "components/main.njk" as c %}
...
{{ c.heartButton("blog/", "Wolf with a Blog (Main)", true) }}
See? Much less space than the raw HTML content.
I also have macros for the article select dropdowns in funblogs, entries on this very blog's index, display of tags on this very blog, and more! Yeah, you can pass entire arrays to the macro.
Converting Silver Scripts
The Silver Scripts subsite remained unconverted for a longer period than the rest of the site, because, again, it was built with the vanilla Rarebit template (which I thought was totally awesome), and I wanted to wait a while before converting a different site structure.
Rarebit was supposed to be an easy way for web cartoonists to create custom sites for their webcomics with minimal code. There was a JavaScript file for the creator to put data about their comics, and then the homepage would show a different comic with a query string ("?page=1") in the URL and the archive would have its tables written by another JavaScript file. Oh, and I'll mention again that the header and footer on every page were rendered by embedded JavaScript scripts.
This may be okay for those who don't want to wade into code more, but my website is basically a web development project. Silver Scripts had to catch up eventually. One big problem Rarebit posed was that Google had lots of trouble indexing the comic pages due to there technically being only one webpage for the comic, and code to change the canonical URL for each comic couldn't cut it. Also, Rarebit didn't seem to be updated anymore, even when I began using it.
In December 2025, the Eleventy makeover for the Silver Scripts subsite went as you would expect. I replaced the header and footer scripts with using a layout file.
For generating the comics pages themselves, I created an individual HTML file for each comic, because JSON doesn't support multiline values, and took advantage of the pagination feature. Because there's no feature for giving different URLs for the same page (the latest comic), I had to use the extremely hacky method of using the virtually same code on the homepage, just pointing the variables to the latest comic, like this:
title: "{{ collections.silverScripts[collections.silverScripts.length - 1].data.pgNum }}. {{ collections.silverScripts[collections.silverScripts.length - 1].data.title | safe }}"
Yeah.
For the archives, I could use the collections feature to tag comics by arc and, within each arc's table, generate the rows per comic iteratively.
<h3 id="arc1">Arc 1: {{ c.arcName(1) }}{% endcall %}
<table class="archiveTable">
<tbody>
{%- for comic in collections.ssArc1 -%}
<tr class="archiveRow" data-href="{{ comic.data.pgNum }}-{{ comic.data.title | slugify }}.html">
<td class="archiveCellNum">
<span><strong>{{ comic.data.pgNum }}</strong></span>
</td>
<td class="archiveCellTitle leftAlignTableText">
<span><strong>{{ comic.data.title }}</strong></span>
</td><td class="archiveCellDate">
<span> {{ comic.date | postDate }} </span>
</td>
</tr>
{%- endfor -%}
</tbody>
</table>
I still had to use the old archive JavaScript file with this function added (but everything else turned off):
document.querySelectorAll('.archiveRow').forEach(row => {
row.addEventListener('click', () => {
window.location.href = row.dataset.href;
});
})
Yes, reusable components strike again for storing and using arc names to be consistent. Along with the comic navigation bar, I set up an array of arc names (it's a good thing the first arc is indexed at zero) and macros for retrieving the arc name and rendering an arc link on a comic page.
{% set arc_names = [
"A Glimpse",
"I Can't Believe I Made It!"
] %}
(Did you know you can get the first page, last page, and total number of pages from pagination also?)
Regarding the image HTML transformation plugin, I just added a link to the original image for the comic on each page.
Conclusion (and oh crap, is Eleventy a goner?)
There were more things I achieved with Eleventy, namely RSS feeds, but those are beyond scope. I intended this to be my story of my migration from vanilla to Eleventy, so it can benefit if you're considering such migration too.
(If you're stuck on some parts, the source code for my website is available on GitHub. Please note to check the version that was around this blogpost's writing, which can be accessed through tags.)
While there were so tedious parts here and there, Eleventy was relatively straightforward to use for migrating my website to a static site generator. I could now generate parts automatically with minimal code. No massive changes to webpage code were needed. I could not recommend Eleventy more than enough.
However, in the midst of writing this blogpost, it has been announced that Eleventy is becoming a monetized version called Build Awesome, owned by the guys behind Font Awesome. Taking an entirely free software and adding paid features on top of it? This is not cool, especially for a site builder that's friendly to newcomers. (Also, the new mascot design pales compared to its previous design.) This may be catastrophizing, but sooner than I expected, this blogpost may as well be obsolete, and I may as well be using Astro instead. Only time will tell.
I am still publishing this blogpost in hopes that my knowledge will benefit others for the greater, such as those using Eleventy as of this moment. Eleventy's still there, and it's still the simpler static site generator.
Oh, while Astro does have a guide for converting from Eleventy, it's not that "step-by-step", telling you to mess around with Astro's documentation and website templates to figure things out.
Now that I've gotten the blogpost on the Eleventy conversion knocked out, I will be able to tell more of my stories about computer science and perhaps life itself! Stay tuned.
Oh, and if this blogpost helped you with converting your website to Eleventy and you're going to write about your migration, please link this post! I'll know.
(I had to enclose Nunjucks code with {% raw %}{% endraw %} tags in the code embeds, so that the webpage wouldn't render the code. sigh)