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/articles
folder. - You want to change a directory name, so the
/articles/first-article.md
is 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.