Quick and dirty static web site generator
Switch branches/tags
Nothing to show
Clone or download
Latest commit e754c9a Jan 18, 2017



Loki is a low-key, quick and dirty utility to build static web pages using a simple templating format. Loki was built with the specific needs of building the Lensflare website in mind (i.e., keeping things templated and organized, and also to manage game manuals and such in a consistent fashion) -- to see an example of it in use, take a look at the source repo for the web site. I did, however, make minimal efforts to keep it generally useful while I was working on the site, so maybe it would be useful to someone whose needs were sufficiently similar.

Think of Loki as the exact opposite of something like Sinatra: instead of a dynamic web server, it generates a bunch of HTML pages to be uploaded to a static web server somewhere.

Getting Started

To get started, run the following command (you'll need to the bundler gem installed):

bundle install

To install the gem, run the following commands (optionally replacing * with the current version):

gem build loki.gemspec
gem install loki-*.gem

You can then run the loki command anywhere like this:

loki <source-dir> <destination-dir>

If you don't want to install the gem, you can alternately run loki from the loki repository directory like so:

bin/loki <source-dir> <destination-dir>

If you'd like to run the tests, run:


There's also a pretty primitive Makefile that does all this for you (i.e., run make, or run make clean to uninstall and remove the gem).

Why Loki?

Why not? It's useful for me.

I wanted something to manage a static web page that had gotten a bit unwieldy, especially my online manuals (which were really a bit of a mess). The blog stuff I threw in because, well, why not. I didn't have particularly heavyweight needs there, either, and liked the idea of having complete control over everything.

Is it useful for you? Who knows? Maybe if your needs are similar enough (i.e., if you need to manage a static site where you write the pages locally and FTP them up to a static server or whatever. If you have online manuals to manage. If you're a clone of me in a very similar parallel universe. I don't judge). It probably isn't the easiest software to use for whatever passes for normal people, and it does (gasp) involve programming some pretty minimal Ruby, but honestly you don't really need to know anything about Ruby to use it (though it would help to debug issues with your pages. Hopefully I've made the error messages expressive enough -- and the software stable enough -- that you wouldn't need that, though). That said, it is pretty oriented towards programmers, so your mileage may vary.

If you are a programmer, I've also made some effort to keep the code tested and somewhat maintainable, although I know I haven't done a perfect job there, I don't think it's too bad, either. So screwing around with Loki itself hopefully wouldn't be too intimidating.

Anyway, writing it was relaxing, and I needed something to unwind after I quit one job before I really dived into the next one, so writing it had some value to me independent of any usefulness it might or might not objectively have.

Using Loki

Loki requires a source and destination directory, and both of these paths must exist before running loki. In addition, the source directory must contain the views, assets, and components directories before loki will run.

The views directory contains the pages generated by loki. Any files in that directory or any of its subdirectories will be interpreted as a page.

The assets directory contains any other objects (images, scripts, css, any other random files) referred to in those pages. Any assets will be copied to the destination directory if (and only if) referenced by a page.

The components directory contains any components (templates, partials) needed to build the pages.

Loki Pages

Loki pages have two sections: metadata and body. The sections are separated by two dashes (--) on a line by itself. Bodies can contain any arbitrary HTML and directives (see below) enclosed in curly braces ({ and }). For example, a page might look like:

id "home"
title "my home page"
css ["css/default.css"]

<h1>This is my home page</h1>

Today is {Time.now}.

{link("about", "About Me", {class: "my-link"}})}

This might end up looking something like:

  <title>my home page</title>
  <link rel="stylesheet" href="assets/css/default.css" type="text/css" />
<div class="header">my header</div>

<h1>This is my home page</h1>

Today is 2016-03-28 00:05:56 -0600.

<a href="/about.html" class="my-link">About Me</a>
<div class="footer">my footer</div>

Metadata Parameters

The following parameters are available:

  • id: page id; must be unique. This is used to reference other pages with the link directive (see below).

  • title: page title (will go in the head).

  • template: template used for this page (if set). The template with the given name/path must exist in the components directory.

  • tags: a list of tags

  • css: a list of css files/paths; the files must exist in the assets directory and will be copied when referenced.

  • javascript: a list of javascript files; the files/paths must exist in the assets directory and will be copied when referenced.

  • favicon: a list of lists, each containing an (integer) size, a string with the type, and a string with the path, which must exist in the assets directory, and will be copied when referenced.

  • set: custom metadata fields; requires two arguments: a key and a value. For example, if set :foo, "bar" is used in a page's metadata, {page.foo} in the body would insert bar into the page at that point.

  • global: custom metadata fields; requires two arguments: a key and a value. For example, if global :foo, "bar" is used in a page's metadata, {site.foo} in the body would insert bar into the page at that point. Unlike with set, this value can be accessed in the body of any page on the site, not just the current page.

  • manual_data: data for generating online manuals; see the manuals section below.

  • head: takes an arbitrary string, and inserts it into the page head

Values must be inside strings (they are interpreted as ruby strings; values can also be returned from a do-end block). You can also put arbitrary ruby code in the metadata. The site and page objects can also be used to return values that are already set via set or global (or via the config hooks below).

Loki Directives

Any blocks of ruby code can be inserted inside of curly brackets ({}) in page bodies. This can be used to insert files or calculate values, etc. The following directives are available in the interpretation scope:

  • body: only legal in templates, will include the page body (this is required somewhere in the template for it to meaningfully function as a template)

  • page: the current page object; this can be used to access values set in metadata. For example {page.id} would insert the value of the current page's id into the body.

  • page.set(<key>, <value>): sets a value for the page (same as set for metadata). For example, if {page.set :foo, "bar"} is inserted somewhere in the page, {page.foo} later in the body would insert bar into the page at that point.

  • site: same as above, only for global site values.

  • site.set(<key>, <value>): sets a value for the site. For example, if {site.set :foo, "bar"} is inserted somewhere in the page, {site.foo} later in the body (or in the body of any page evaluated later) would insert bar into the page at that point. This is a lot less useful that setting a global value in the metadata, since this wouldn't be evaluated until the page is being built and would only be accessible from other pages being built after it; values set in metadata happen when the pages are loaded before any of the pages are built and any evaluation happens, so are available to all pages no matter the order of processing. Also see config hooks below.

  • include(<partial>): includes another file from the components directory

  • link(<id/path>, <text>): inserts a link to another page or asset; pages are referred by id, assets by path (don't include the 'asset' directory in the path, that will be prepended automatically). Text will be the text of the link (or HTML or whatever). Assets must exist in the source assets directory and will be copied to the destination directory.

  • link_abs(<url>, <text>): inserts an arbitrary absolute link. Text is same as above.

  • image(<path>): inserts an image. Image must exist in source assets directory and will be copied to the destination directory.

link, link_abs, and image can be passed an options hash as the last argument; an :id key is used for ids, :class for classes, and :style for styles. Passing the option :self_class to links will instruct them to use the supplied class if the link points to itself. Passing the option :append will append the value to the link, i.e., something like #top could be appended to the end of the url to link to the id top inside a page. Passing the option :alt to an image will specify an alt-text for that image.

  • table(<data>): inserts a table; the data argument should be a square array of arrays (i.e., lists of the same length for each row inside of an outer list). An options hash containing :id or :class can also be supplied (a :style option would only apply to the top-level table tag; there are three different kinds of HTML tags in a table -- table, tr, and td -- use CSS to target them specifically. If finer control is required, it's probably better to just create the table using HTML yourself).

  • manual_ref(<section>, <text>): relative link to another section of a manual (generally this is used inside of manual_data, but at minimum manual_data must be defined for the current page). This will render <text> with a link to the correct section of the manual; if <text> is omitted, the section name will be used. See the manuals section below for more information.

  • render_manual: generates manual HTML from manual data as defined in the metadata; see the manuals section below.

Using a double open curly brace ({{) will result in a literal curly brace ({) in the destination file. Using a double close curly brace (}}) inside an evaluation context will be a literal curly brace (}) instead of closing the context. You can put arbitrary ruby code into the evaluation blocks, e.g., {Time.now.year} would insert the year at that point in the page.

Config Hooks

There are two special files in the top level of the source directory that (if they exist) will be loaded at two different points in the run. The first is config.rb which is run before loading any of the pages. The second is config_load.rb which is run after all the pages are loaded but before they're built/evaluated.

The contents of these files can be any arbitrary ruby, but the most important use for these files is to set global site values. This is done with the set function. For example, if the config.rb contains the line set :foo, "bar", when {site.foo} is included in any of the pages on the site, it will insert bar at that point.

The following directives are available in this interpretation scope:

  • set: custom fields for site reference; requires two arguments: a key and a value. For example, if set :foo, "bar" is used in config.rb, {site.foo} in the body of any page would insert bar into the page at that point (this is effectively the same as the global directive in page metadata).

  • blog_config: configuration for managing blog posts; see the Blog Pages section below.

Automatically Generating Manuals

Loki has built-in directives for generating dynamic manuals. The way this works is that the data (text/html) used to build the manual is defined in the page metadata with the manual_data directive, and the render_manual directive is used in the body to render it.

The data for the manual must be list defined as such:

[<manual-name>, <introduction-text>, <section>, <section>...]

Where each section in the list of sections follows the same format:

[<section-name>, <text>, <optional-subsections>, <optional-subsection>...]

Where the optional subsections can be omitted if none exist.

For instance, a very simple manual might look like this:

["My Manual",
  "This is some introductory text",
  ["First Section",
    "This is the first section text"],
  ["Second Section",
    "This is the second section text",
    ["Subsection", "This is text for a subsection"],
    ["Another Subsection", "This is text for another subsection"]
  ["Third Section",
    "This is the third section text"]

Each of the sections will be automatically numbered and inserted in turn with the render_manual directive, with a table of contents inserted after the introduction text. The sections can be regular HTML and will be inserted after sections headers. Each of the sections will be parsed, so any Loki directives can be used; in particular, the manual_ref directive can be used to make relative links to other sections. References to other sections are made by name, and must include the entire hierarchy separated by pipes (|), i.e., the first subsection above would be referred to as so:

manual_ref('Second Section|Subsection')

If the section doesn't exist, it will raise an error. Note that section 1 will always be referred to as Introduction in the table of contents and should be referred to the same way by manual_ref; the first header will be the manual name as supplied to manual_data, however.

Blog Pages

There are two parts to setting up a blog: global configuration and entries. The global configuration is handled in the config.rb (see Config Hooks above) by using blog_config. The other part is a by having files (similar to views) for individual entries.

All of the same page metadata parameters (except manual_data) can (or should) be configured either in the blog_config block, or in the individual entries. template, css, javascript, favicon, and head can be set in blog_config block. In addition to those, the following parameters can also found there:

  • main_title: the main title of the blog; this will be used for the main blog pages, i.e., the blog entry list pages.

  • main_template: the template used for the main blog pages, as above (and also for generated pages for tags, see tag_pages below).

  • entry_template: the template used for the blog entries.

  • directory: source directory where blog entries are found. This is required; I may eventually add support for multiple blog_config blocks with distinct directories, but haven't bothered yet. Note that filenames will be used for the blog entry URLs, so should only use valid HTTP URL characters.

  • tag_pages: build static tag pages. These can get out of control if you use a lot of tags since they aren't dynamically generated by a db backend like a normal (i.e., a real) blog server might do, so it's possibly you might want to shut them off (and false is the default). If they aren't generated, though, then tags on pages won't link to anything. The tag pages will include static pages (i.e., regular non-blog-entry pages) with given tags in addition to blog entries.

  • generate_rss: generates an RSS feed that can be linked to with rss_feed below; defaults to false.

  • main_date_format: the format used for displaying dates on the blog list pages. Defaults to "%Y-%m-%d %H:%M".

  • description: used for the blog description in RSS.

  • site_link: main link to your page for RSS (don't use anything else or the entry links will be broken. Also don't use a trailing slash).

  • entries_per_page: how many entries will appear on your main blog page before pagination kicks in.

For example, you might have something like this in config.rb:

blog_config do
  main_title "my blog"
  main_template "my.template"
  entry_template "my.template"
  directory "blog_entries"
  css ["my.css"]

Note that the main blog page will have blog as its ID so you can link to it from other pages.

On each individual entry, the id (required), title, tags, set, and global parameters are available (as in normal view metadata), and all should be found before a -- separator (as with normal view metadata). In addition, the following parameters are available:

  • date (required): this should be a valid date-time string (anything Time.parse can handle is valid, so most reasonable strings should be fine; it's up to the blog author to supply something reasonable, however -- do note that not supplying a year will cause the entry to be the current year when the blog page is generated by Loki which may not be what you intended, so fully specifying the date is recommended). We can probably pretend that timezones don't exist, though, since dates are being defined on the same machine that's generating date links and such, but if that matters to you, well, look up Time.parse and give it what it wants.

  • description: description for RSS; required if generating RSS.

Inside of entries (or the blog template), all of the (non-manual) directives are available. In addition, the following are also available:

  • date(<format>): returns date for page. Can be passed optional strftime format.

  • date_sidebar: generates HTML to navigate entries by day/month/year, including javascript to expand and collapse periods. Includes links to individual entries.

  • tag_sidebar: generates lists of tags and counts of pages for each tags; includes links if tag_pages is true.

  • tag_list: list of tags for current entry; links if tag_links is true.

  • rss_feed(<text>): inserts link to RSS feed for blog. Options are handled the same as with other links.

  • next_link, previous_link, oldest_link, newest_link: links for navigating between blog entries.

Maintainer and License

Douglas Triggs [douglas@triggs.org] is responsible for Loki. He also likes inconsistently referring to himself in the third person.

Loki is available under the Apache 2.0 license; don't blame me if it doesn't work for you, but if you ask nicely I might just help you get it running, who knows? Hopefully these instructions are enough to make it pretty painless, though.


Probably nothing else major at this point, unless I need to implement a new feature or something. I might do some random refactoring to clean things up a bit (in hindsight, having blog entries and pages as separate things might not be the best idea, for one). Also, the tests aren't really the greatest.

It's good enough for now, anyway.