Customizing File Structure, URLs and Browsersync

Find out how I customize Eleventy to achieve my opinionated requirements about the structure of source files, output files and URLs.

Eleventy offers zero configuration (file structure and URLs) out-of-the-box. It adopts the concept of Cool URIs don't change - the generated URLs will follow its filename by default, with no file extensions. For example, if you have an about-me.html source file, the URL for it would be /about-me/.

This is great but not exactly what I want because:

Feel free to dive into the source code jec-11ty-starter straight away. This is the 3rd post of the series. Here is the previous post - Setting up GitHub Actions and Firebase Hosting.

Organizing source files

I organized all source files under subfolders. General pages like /licenses, /blog listing, /404, even the home page are placed under a folder named root.

For content pages like blog posts and presentation decks, they will be organized:

For instance, the "Web Performance Optimization" presentation is placed under the deck folder and the file name would be "2019-08-31-web-performance-optimization.md".

Below is an overview of how source files are structured:

# Source file structure

src
- root
- index.njk
- licenses.njk
- blog.njk
- blog
- 2020-05-19-post-one.md

In future, I might add in a new content category named read. I will then add a read.njk in the root folder and create a read folder for all my reading reviews.

* If you are wondering about the file extensions here, I use markdown .md file for content writing and Nunjucks .njk file for general pages that need more html manipulation.

How does 11ty process our source files?

Eleventy will process our source files (both .njk and .md) into .html pages by default. Base on the above file structure, this would be the output:-

# Output file structure by default

dist
- root
- index.html
- licenses
- index.html
- blog
- index.html
- blog
- 2020-05-19-post-one
- index.html

Each source file will output 2 items:

.

Based on the above output files, you can probably guess the website URLs! Here is an overview of the URLs:

# Website URLs by default

- /root/
- /root/licenses/
- /root/blog/
- /blog/2020-05-19-post-one/

Erm... that doesn't look like what I want.

What is my expected output?

There is nothing wrong with the default output, 11ty processes our files naively based on our input structure by default. However, that is not what I want.

Here are my requirements:

Here is the output I'd like:-

# My expected output

dist
- index.html
- licenses.html
- blog.html
- deck.html
- blog
- post-one.html # no date

What do I mean by "URLs should be clean"? I prefer URLs with no trailing slash and no file extension. For example:-

I prefer the first URL format. However, with the above output files, when the user browses to licenses page, for example, she will land on /licenses.html, oops! đŸ˜Ĩ

No worryies though, we've got that covered in the previous post. We have updated the server settings (Firebase cleanURLs) to eliminate file extensions when users browse to our page, so /licenses it is. No file extension, no trailing slash. 😍

So what we need to do now is play with 11ty's configurations to generate the above output.

TLDR: Does website URLs matter? Trailing slash, file extension or date.

No, it doesn't impact the searchability (aka SEO) of your site, and users might not even care about or notice that.

But! It matters to me. This is my site, the trailing slash and extension hurt my eyes. 😆 I don't want date information in URLs because it is not meaningful (I might update the content from time to time).

Coincidentally, my colleague - Jake did a Twitter poll on the same topic few days ago. 😆 Mathias replied on it as well. My preference (and the majority) is same as Mathias: prefer links without trailing slash.

Jake and Mathias tweet about web urls, and my replies to it.
Jake and Mathias tweet about web urls, and my replies to it.

What are the solutions then?

Now that we understand the requirements, let's solve that! After reading the documentation multiple times and performing various testing. I found a few ways to achieve the desired result. Let us go through it one by one, from basic to pro. 😎

First pass: Adding permalink in each file

It is quite easy to change the output filename. We just need to update the permalink on the file's Front Matter Data

Let's look at our licenses.njk file as an example.

<!-- root/licenses.njk -->

---
title: Licenses
permalink: licenses.html
---
<html>
<head>
<title>{{ title }}</title>
</head>
<body>
Hello licenses page.
</body>
</html>

The --- top section is something we called Front Matter Data (briefly mentioned in 1st post). 11ty wil preprocess the Front Matter Data before the template.

There are some built-in Front Matter Data that we can use. Permalink is one of them, and it is a special one (further explained later) because permalink field gives us a way to change the output file format and location. In our case, src/root/licenses.njk will now output as dist/licenses.html, without root in it and no extra folder!

More information on Front Matter Data is available in the documentation.

Second pass: Use 11ty supplied data in permalink

Editing permalink in file manually is prone to typing errors. We can improve our manual typing slightly by utilizing some of the Eleventy Supplied Data. Here is the list of supplied data that we can use.

We will be using the page.filePathStem supplied data. It provides us the full filename - without the date prefix and extension.

Here are the filePathStem value for each source files:

source filefilePathStem
root/index.njk/root/index
root/licenses.njk/root/licenses
root/blog.njk/root/blog
blog/2020-05-19-post-one.md/blog/post-one

Look at the blog post, the date information is gone. One more thing we need to solve though. For the general files, we want the output to be without /root. We achieve that by using the Nunjucks built-in replace filter.

Let's update our permalink to use the page.filePathStem:-

<!-- root/licenses.njk -->

---
permalink: "{{ page.filePathStem | replace('/root/', '/') }}.html"
---

...

There you go. Remember I mentioned earlier that permalink is a special one? It is because it allows us to write code. The logic will be interpolated in later stages.

Third pass: Setting permalink per directory

Well, both the first and second pass require us to update each file manually, so it is time-wasting! Is there a better way? Let's go up one level, where we can set the permalink data per directory by creating a Directory Data File per folder.

Here is the new file structure.

# File structure

- src
- root
- root.11tydata.js # add this
- index.njk
- licenses.njk
- blog.njk
- blog
- blog.11tydata.js # add this
- 2020-05-19-post-one.njk

Please note that the Directory Data file name:

Okay, let's update both of our Directory Data files.

// root/root.11tydata.js
// blog/blog.11tydata.js

module.exports = {
permalink: "{{page.filePathStem | replace('/root/', '/')}}.html"
};

It's okay for us to use the same code. In fact, you can shorten the permalink in blog.11ty.data.js to just {{page.filePathStem}}.html if you want to, but I prefer to use the same logic.

With this setting, each file under the same directory will get the permalink value injected automatically. We don't need to update every file manually.

In case you have a file in the directory that needs a special permalink, you can still override it using the Front Matter Data in each file. For example, let's say we add a RSS file in the root folder atom.njk. We want the output to be index.xml - no /root in name and the file extension should be xml.

Here is how we can do it. 👇đŸŧ The priority in the file Front Matter Data is higher than the Directiry Template Data and hence value get overridden.

// root/atom.njk

module.exports = {
permalink: index.xml
};

Final pass: Do it once, setting permalink globally

Still, I wasn't happy with the per-directory approach, as you probably do too. I want to find a way to do it just once and forget about it.

After reading the documentation - real world example and data precedence a few times, and through various testing, I found a way to do it.

11ty accepts a global data folder _data (customizable). We can place our global data or function here (will talk more about this in the coming post).

There is one special file we can add into this folder - the Computed Data File. Name it as eleventyComputed.js (must be this name). Let's create the folder and file.

# file structure
# add _data folder and `eleventyComputed.js`

- src
- _data
- eleventyComputed.js
...

Here is the code for our eleventyComputed.js file.

// _data/eleventyComputed.js

module.exports = {
permalink:
'{% set p = page.filePathStem | replace("/root/", "/") %}' +
'{{ permalink or (p + ".html)" }}'
};

Notes for Nunjucks newbies:

  • Nunjucks uses {% %} as the template syntax.
  • Nunjucks uses {{ }} for interpolation.
  • The built-in set tag is used to create/modify a variable.

The code is slightly different from our Data Directory Data, we add an or statement here:-

Basically, the permalink value in the global _data/eleventyComputed.js directory has the highest priority of all. It will override the value in all Data Directory Data and Front Matter Data files. Refer to the advanced details section in the documentation.

We don't want the permalink to be overridden if we have set it somewhere else for a special case (e.g. the RSS file). Therefore, we add an or statement to check that.

Voila! No more per file nor per directory settings. Just one global computed data to rule it all. 😃

One more thing... configure our localhost

If you run npm start to serve the project now, it will show a "page not found" error when you browse to /licenses page. However, it works when you browse to /licenses.html.

What happened is that we configured our production server (Firebase Hosting) to handle the cleanURLs (eliminate .html), but we have not configured our local server yet.

Eleventy uses Browsersync under the hood to serve our local files. The good news is we can customize that too! Let's configure that in our .eleventy.js configuration file.

// .eleventy.js

module.exports = function (eleventyConfig) {

// Browsersync config
eleventyConfig.setBrowserSyncConfig(
// will add our configuration code here

);

...
}

The configuration code is slightly lengthy, so I placed that in a separate file configs/browsersync.config.js. (I might consider creating an npm package in future, maybe!)

// configs/browsersync.config.js

const fs = require('fs');
const url = require('url');

module.exports = (output) => ({
server: {
baseDir: `${output}`,
middleware: [
function (req, res, next) {
let file = url.parse(req.url);
file = file.pathname;
file = file.replace(/\/+$/, ''); // remove trailing hash
file = `${output}/${file}.html`;

if (fs.existsSync(file)) {
const content = fs.readFileSync(file);
res.write(content);
res.writeHead(200);
res.end();
} else {
return next();
}
},
],
},
callbacks: {
ready: function (_, bs) {
bs.addMiddleware('*', (_, res) => {
const content = fs.readFileSync(`${output}/404.html`);
res.write(content);
res.writeHead(404);
res.end();
});
},
},
});

No worries, it's okay to skip reading these code, just use it! 😉 Next, update our .eleventy.js file to use that.

// .eleventy.js

module.exports = function (eleventyConfig) {

// Browsersync config
eleventyConfig.setBrowserSyncConfig(
// add this line - dist is our output directory
require('./configs/browsersync.config')('dist')
);

...
}

Alrighty, what's next?

Yay! We have set up our file structure, cleaned the URLs and configure Browsersync to support that. 🎉 We have learned about Front Matter Data, Data File Directory Computed Data, the global folder and Browsersync too.

That's how my site jec.fyi was set up as well.

TLDR; Does these effort worth it?

Actually... you might not need this. Seriously. Just go with the default settings and you will be happy, hah!

I am opionated as I have mentioned. I am surprised that I can go so far and even write a whole blog post about this, basically just to:-

  • make my output file structure look good
  • make the URLs look good

(No offense all, good in my definition! 😂)

Nevertheless, I enjoy the process of exploration and testing the flexibility of Eleventy. Turns out, there is a lot of customization you can do with it.

In the coming posts, I plan to write about more on how I built my website with 11ty:

Let me know if the above topics interest you.

Here's the GitHub repo for the code above: jec-11ty-starter. I'll update the repo whenever I write a new post.

That's all. Happy coding!

Have something to say? Leave me comments on Twitter 👇đŸŧ

Follow my writing:

Hand-crafted with love by Jecelyn Yeen Š Licenses