Lume 2 is finally here!!
by Óscar Otero
13 min read
After several months of work, a major version of Lume was released. In this new version, I wanted to take this opportunity to fix some bad design decisions, remove some confusing APIs, and implement a couple of new features.
An yes, there are some breaking changes. I tried to make the transition from Lume 1 to Lume 2 as smoothly as possible, but not always possible. I'm sorry for the trouble!
TL/DR
Tip
There's a step-by-step guide to migrate to Lume 2 in the documentation.
Note
The documentation for Lume 1 is still visible at v1.lume.land.
Vento is the new default template engine
The default template engine in Lume 1 is Nunjucks, which is a great and battle-tested template engine, this is why it was enabled by default.
But Nunjucks has some limitations, especially with async functions.
Vento is a new template engine that I've created. It
was available in Lume 1 through the
vento plugin, but in Lume 2 it's enabled
by default. Some of the strengths of Vento:
- Created natively with Deno in TypeScript.
- It has an API similar to Nunjucks (but not the same) so it's very ergonomic to use.
- It works great with async functions.
- You can use javascript inside the templates, so no need to use filters for trivial things like converting to uppercase or filtering an array.
Nunjucks is also available but not installed by default. If you want to keep
using it, just import the plugin in the _config.ts file:
import lume from "lume/mod.ts";
import nunjucks from "lume/plugins/nunjucks.ts";
const site = lume();
site.use(nunjucks());
export default site;
New basename variable
Although Lume allows to customize the final URL of the pages, there are some cases not easy to achieve:
- If you want to remove a directory name: For example to export all pages in
/articles/without the/articlesfolder. - You want to change a directory name, so the
/articles/first-article.mdis output as/news/first-article/.
To achieve that in Lume 1, you have to build the new URL completely by creating
a url() function or setting it manually in the front matter of every page.
In Lume 2 you can change how a folder or a page affects the final URL with the
basename variable. It's a special variable (like url) but it only affects
this part of the URL, so it's much easier to make small tweaks. You only have to
define a _data.* file in the folder which name you want to change, with the
basename variable. For example, to remove the directory name from the final
URL, just set the basename as empty:
# /articles/_data.yml
basename: "" # Don't use "/articles" in the final URL
To change the directory name:
# /articles/_data.yml
basename: news # Use "/news" instead of "/articles" in the final URL.
Note that you can also remove or add additional paths:
basename: "../" # Remove the current and previous folder name
basename: "articles/news" # Create a subfolder
The basename variable can be used in the pages frontmatter, to change the
filename part in the final URL:
# /posts/post1.md
title: My first post
basename: my-first-post # Create the URL /posts/my-first-post/
The basename variable is defined automatically if it's missing. So you can use it to search pages:
pages = search.pages("basename=index");
Lume 1 has something similar with the slug variable in the page.src. This
variable was removed, so if you are using it to generate custom URL, you have to
modify the url function. Example:
// Lume 1
export function url(page) {
return `/articles/${page.src.slug}/`;
}
// Lume 2
export function url(page) {
return `/articles/${page.data.basename}/`;
}
More info at lume.land docs.
Changes in process, preprocess, processAll and preprocessAll
In Lume 1, if you want to modify pages, you can use the site.process function.
For example:
site.process([".html"], (page) => {
page.document?.querySelectorAll("a[href^='http']", (a) => {
a.setAttribute("target", "_blank");
});
});
But after implementing this, we realized that sometimes we need to run some code
after or before the processor. So we had to add the processAll function, that
works similar to process but receiving all pages at the same time:
site.processAll([".html"], (pages) => {
console.log("Preparing");
for (const page of pages) {
page.document?.querySelectorAll("a[href^='http']", (a) => {
a.setAttribute("target", "_blank");
});
}
console.log("Done!");
});
As you can see, processAll is much more flexible than process: you not only
can run code after or before processing the pages but also decide if the code
must be run in parallel (for async operations or sequentially):
// Run the code in parallel
site.processAll([".html"], (pages) => {
return Promise.all(pages.map(asyncFunction));
});
Due processAll can do the same as process and more, for simplicity in Lume 2
the process function has changed to behave like processAll, and processAll
was removed. The same change has been applied to preprocess and
preprocessAll functions.
// Lume 1
site.process([".html"], (page) => {
modifyPage(page);
});
// Lume 2
site.process([".html"], (pages) => {
pages.forEach(modifyPage);
});
// Async functions sequentially:
site.process([".html"], async (pages) => {
for (const page of pages) {
await modifyPage(page);
}
});
// Async functions in parallel:
site.process([".html"], async (pages) => {
await Promise.all(pages.map(modifyPage));
});
Lume has the concurrent utility that works similar to Promise.all but allows
to customize the number of processes running at the same time, which is useful
to avoid memory issues. It's the function used in Lume 1 to run the processors
in process and by default is limited to 200 processes.
import { concurrent } from "lume/core/utils/concurrent.ts";
site.process([".html"], async (pages) => {
await concurrent(pages, modifyPage);
});
Search returns the page data
The search plugin provides the search
helper with some useful functions like search.pages() to return an array of
pages that meet a query. In Lume 1, this array contained the Page object, so
you had to access the page data from the data property. For example:
{{ for article of search.pages("type=article") }}
<a href="{{ article.data.url }}">
<h1>{{ article.data.title }}</h1>
</a>
{{ /for }}
In Lume 2, the function returns the data object directly, so the code above is simplified to:
{{ for article of search.pages("type=article") }}
<a href="{{ article.url }}">
<h1>{{ article.title }}</h1>
</a>
{{ /for }}
This behavior was available in Lume 1 by configuring the plugin with the option
returnPageData=true. In Lume 2 this is the default behavior and the option was
removed.
See the GitHub issue for more info.
This change also affects search.page() (the singular version of
search.pages()). The data filter, registered by this plugin in Lume v1 was
also removed because it's now useless.
Removed search.tags()
The function search.tags() was just a shortcut of search.values("tags"). It
was removed in Lume 2 for simplicity.
Replaced imagick with image_transform
The imagick plugins in Lume 1 allows to transform the images of your site
using the magick-wasm package. In
Lume 2 this plugin was renamed to image_transform and uses
Sharp under the hood.
Sharp is more performant, with a nice API and support for SVG format. Both
plugins works similarly and in most cases you only have to change the data key
used (imagick in the imagick plugin, imageTransform in the
image_transform plugin).
This change affects also to the picture plugin that now uses the
transform-images attribute instead of imagick.
See the
docs for image_transform.
TypeScript improvements
Although in Lume 1 it's possible to import and use the Lume types, it's not very
ergonomic. Lume 2 registers the global namespace Lume containing some useful
types.
To use the new Lume types, update the deno.json by adding the types key to
the compilerOption entry:
{
// ...
"compilerOptions": {
"types": [
"lume/types.ts"
]
}
}
Now you can use the Lume namespace anywhere, without needing to import it
manually:
export default function (data: Lume.Data, helpers: Lume.Helpers) {
return `<h1>${data.title}</h1>`;
}
DOM types
Lume uses deno-dom to manipulate the
HTML pages. It's an awesome package but it uses its types and that's not very
ergonomic. Let's see this example:
import type { Element } "lume/deps/dom.ts";
const document = page.document;
document.querySelectorAll("img").forEach((img) => {
const src = (img as Element).getAttribute("src");
})
With deno-dom types, you have to add the
type assertion for Element,
so you have to import the Element type.
Lume 2 loads the standard libraries
dom and dom.iterable
(that are available by default in TypeScript but disabled in Deno). The code
above can be simplified to:
const document = page.document as Document;
document.querySelectorAll("img").forEach((img) => {
const src = img.getAttribute("src");
})
deno-dom is still used under the hood but with different types, which makes
the code much more interoperable between back and front. And no need to import
anything because DOM types are available everywhere.
Search plugin
Another nice addition is the generics to the search helper, so you can search
pages with search.pages<MyCustomPage>().
Removed sub-extensions from layouts
Some extensions like .js, .ts, or .jsx can be used to generate pages or
javascript files to be executed by the browser. To make a distinction between
these two purposes, Lume 1 uses the .tmpl sub-extension. For example, you can
create the homepage of your website with the file index.tmpl.js (from which
the index.html file is generated) and also have the file /carousel.js with
some JavaScript code for an interactive carousel in the UI (maybe bundled or
minified with esbuild or terser plugins).
Lume 1 implementation requires the .tmpl.js extension not only in the main
file but also in the layouts. This makes no sense because layouts don't need to
be distinguished from other layouts. It's also inconsistent because
_components JavaScript files don't use the .tmpl sub-extension: to create
the button component, the file must be named as /_components/button.js, and
/_components/button.tmpl.js would fail.
This is an example of Lume 1 structure:
_includes/layout.tmpl.js
_components/button.js
_data.js
index.tmpl.js
Lume 2 doesn't need sub-extension for layouts, so it's more aligned with the components and removes that unnecessary requirement. The previous example would become the following (but not exactly, keep reading below):
_includes/layout.js
_components/button.js
_data.js
index.tmpl.js
Renamed .tmpl to .page
The .tmpl sub-extension is for "template", but it's not a good name because
this file does not work as a template (or not exclusively). Because this
sub-extension is to distinguish page files from other files, the .page
sub-extension makes more sense and is more clear about the real purpose of the
file. So the final site structure for Lume 2 is:
_includes/layout.js
_components/button.js
_data.js
index.page.js
Note that the sub-extension is configurable. If you want to keep using .tmpl
as the sub-extension, just configure the modules and json plugins:
import lume from "lume/mod.ts";
const modules = { pageSubExtension: ".tmpl" };
const json = { pageSubExtension: ".tmpl" };
const site = lume({}, { modules, json });
export default lume;
Tip: I've created a script to automatically rename the files of your repo for Lume 2.
Removed output extension detection from the filename
In Lume 1 the file /example.njk outputs the file /example/index.html, but
it's possible to output a non-HTML file by adding the extension to the filename.
For example /example.css.njk outputs /example.css.
This automatic extension detection has been proven as a bad decision because it
has unexpected behaviors. For example, we may want to create the file
/posts/using-missing.css.njk to talks about the
missing.css library but Lume outputs the file
/posts/using-missing.css and treat it as a CSS file.
Lume 2 removes this automatic detection and all pages will be exported as HTML pages making it more predictable. This not only solves this issue but also align Lume with the behavior of other static site generators like Jekyll and Eleventy. See more info in the GitHub issue.
It's still possible to output non-HTML files by setting the url variable. For
example:
---
url: styles.css
color: red
---
body {
color: {{ color }};
}
Don't prettify the /404.html page by default
Most servers and static hostings like
Vercel,
Netlify,
GitHub Pages
and others are configured by default to serve the /404.html page if the
requested file doesn't exist. It's almost a standard when serving static sites.
Lume has also this option by default. But it has also the prettyUrls option
enabled, so the 404 page is exported to /404/index.html, making the default
option for the 404 page conflict with the default option to prettify the URLs.
In Lume 2 the prettyUrls option is NOT applied if the page is /404, so the
file is saved as /404.html instead of /404/. Note that you can change this
behavior by explicitly setting the url variable in the front matter of the
page.
Changes in multilanguage plugin
The multilanguage plugin in Lume 1
allows to insert inner translations in the page data by using the .[lang]
suffix. For example:
lang: [en, gl, es]
layout: main.vto
title: The Óscar's blog
title.gl: O blog de Óscar # galician translation
title.es: El blog de Óscar # spanish translation
links:
- title: My personal site
title.gl: O meu sitio persoal # galician translation
title.es: Mi sitio personal # spanish translation
url: https://oscarotero.com
- title: Lume
url: https://lume.land
This feature seemed a good idea because you don't have to repeat the links
array only to change the title of some links. The problem of this feature is
Lume needs to traverse the entire data object to find keys with these suffix,
then duplicate the object for each language, reconstruct the object using only
the keys of one language without the suffix... It's a lot of stuff that can
affect to the performance, specially for big sites.
Other problem is sometimes it can produce errors if there are circular references. For example, let's say the page has this data:
export const foo = {};
foo.foo = foo;
Due the foo.foo property is referenced to the foo object, this causes the
RangeError: Maximum call stack size exceeded.
In Lume 2, this feature was removed, which makes this plugin much more performant and removes a can of potential bugs and errors. Note that it's still possible to have values for different languages using the root variables with the same name as the language. For example:
# available languages in this page
lang: [en, gl, es]
# default values to all languages
layout: main.vto
title: The Óscar's blog
links:
- title: My personal site
url: https://oscarotero.com
- title: Lume
url: https://lume.land
# galician translations
gl:
title: O blog de Óscar
links:
- title: O meu sitio persoal
url: https://oscarotero.com
- title: Lume
url: https://lume.land
# spanish translations
es:
title: El blog de Óscar
links:
- title: Mi sitio personal
url: https://oscarotero.com
- title: Lume
url: https://lume.land
Removed WindiCSS plugin and added UnoCSS
WindiCSS is sunsetting. In Lume 2 this plugin has been removed, or rather, replaced with UnoCSS.
See the UnoCSS docs at lume.land.
Changed the behavior of plugins with plugins
One of the goals of Lume plugins is to provide good defaults so, in most cases,
you don't need to customize anything, just use the plugin and that's all. There
are some Lume plugins like postcss or markdown that use other libraries that
also accept plugins:
import reporter from "npm:postcss-reporter";
site.use(postcss({
plugins: [reporter],
}));
The postcss plugin is configured by default to use postcssNesting and
autoprefixer plugins. But setting additional plugins replaces the default
plugins. If you want to keep using the default plugins, you have to use the
keepDefaultPlugins option:
import reporter from "npm:postcss-reporter";
site.use(postcss({
plugins: [reporter],
keepDefaultPlugins: true, // keep using nesting and autoprefixer default plugins, in addition to reporter
}));
The desired behavior in most cases is to add additional plugins, not replace the
default plugins. In Lume 2, adding new plugins doesn't replace the default
plugins. The option keepDefaultPlugins was removed and a new option
useDefaultPlugins was added which is true by default.
This change affects all plugins that accept library-specific plugins like
postcss, markdown, mdx, and remark.
Removed --dev mode
In Lume 1, the --dev mode allows outputting the pages marked as draft. The
problem with this option is it's automatically detected by Lume after the
instantiation, so it's not available before. For example, let's say we have the
following _config.ts file:
import lume from "lume/mod.ts";
const site = lume();
if (site.options.dev) {
// Things to do only in dev mode
}
export default site;
Because dev mode is calculated in the instantiation if we want to instantiate Lume differently depending on whether we are in dev mode or not, we have to detect the flag manually:
import lume from "lume/mod.ts";
const devMode = Deno.args.includes("-d") || Deno.args.includes("--dev");
const site = lume({
dest: devMode ? "_site" : "publish",
});
export default site;
This solution does not work on 100% of the cases because the dev mode can be set
joined with other options, for example: deno task lume -ds (d for dev mode,
s for server).
In addition to that, dev mode can be interpreted as a mode for developers, which is not. In dev mode you don't have more info about errors and the assets are not bundled in a specific way. The only difference is draft pages are not ignored.
The best way to handle this is by using environment variables, so in Lume 2 you
can configure Lume to show the draft pages by setting the variable
LUME_DRAFTS=true. For convenience, you may want to create a task:
{
"tasks": {
"lume": "echo \"import 'lume/cli.ts'\" | deno run --unstable -A -",
"build": "deno task lume",
"serve": "deno task lume -s",
"dev": "LUME_DRAFTS=true deno task lume -s"
}
}
Due to this variable is not longer stored in the site instance, you can access
to it everywhere:
if (Deno.env.get("LUME_DRAFTS") == "true") {
// Things to do when the draft posts are visible
}
If you use Lume CLI, there's the --drafts
option to add automatically this environment variable:
lume -s --drafts
# Runs `LUME_DRAFTS=true deno task lume -s`
Removed --quiet argument
The --quiet flag doesn't output anything to the terminal when building a site.
In Lume 2 this option was replaced with the environment variable LUME_LOGS.
This allows you to configure what level of logs you want to see. It uses the
Deno's std/log library, which allows
configuring 5 levels: DEBUG|INFO|WARNING|ERROR|CRITICAL. By default is INFO.
For example, to only show CRITICAL errors:
LUME_LOGS=CRITICAL deno task build
For convenience, Lume CLI has also the
--debug, --info, --warning, --error and --critical options to add
automatically this environment variable:
lume -s --error
# Runs `LUME_LOGS=error deno task lume -s`
Removed some configuration functions
Removed site.includes()
This function allows to configure the includes folder for some extensions. For
example: site.includes([".css"], "/_includes/css") configure the includes
folder of .css files to the /_includes/css path.
This didn't work consistently and conflicts with the includes option of some
plugins like postcss, for example:
site.use(postcss({
includes: "_includes/css",
}));
site.includes([".css"], "_includes/styles");
In Lume 2, this function was removed and the includes folder is configured only in the plugins.
Merged site.loadComponents(), site.engine() and site.loadPages()
These three functions configure the loader and/or engine used for some extensions for specific cases, but they have some conflicts and can override each other. For example:
site.loadComponents([".njk"], loader, engine);
site.loadPages([".njk"], loader2, engine2);
site.engine([".njk"], engine3);
In reality, when we want to register a new engine (like Nunjucks), we want to
use it to render pages and components, so splitting this configuration into
three different functions didn't make sense. In Lume 2 loadComponents and
engine were removed and loadPages configure automatically the components:
site.loadPages([".njk"], { loader, engine });
And more changes
Please, read the CHANGELOG.md file if you want an exhaustive list of changes.