Skip to content

cborchert/5sg

Repository files navigation

Stupid Simple Svelte Static Site Generator (5SG)

Introduction

5sg stands for stupid simple svelte static site generator. It's a static site generator (SSG) in the making which focuses on ease of development, simplicity of structure, and speed of delivery. It takes in markdown and svelte, and outputs html. I had planned on changing the name, mostly because French google mostly turns up 5th week pregnancy results (5ème semaine de grossesse), but 🤷‍♀️.

Big ideas

Simple build process

  1. Install 5sg using npm install -S 5sg or yarn add 5sg
  2. Put your content files (*.md and .svelte) in <PROJECT_ROOT>/src/content/.
  3. Pick your adventure
    • Static build: run 5sg to build to the <PROJECT_ROOT>/public directory
    • Development: run 5sg --serve to build to the <PROJECT_ROOT>/public directory and serve on http://localhost:3221

Installation using a template

You can install a 5sg template using degit

For a basic starter site use the template at https://github.com/cborchert/5sg-basic-template

npm install -g degit
degit cborchert/5sg-basic-template my-5sg-site
cd my-5sg-site
npm install
npm run dev

For more complicated a blog site use the blog template at https://github.com/cborchert/5sg-blog-template

npm install -g degit
degit cborchert/5sg-blog-template my-5sg-blog
cd my-5sg-blog
npm install
npm run dev

Intuitive, file-based routing

src/content/foo/bar.(md|svelte) generates public/foo/bar.html

Small files and partial hydration

All generated html is feather-weight and the client loads no javascript unless needed.

All images are processed to use modern formats where possible.

Customization and automation

Customize everything from config.js

  • Your sitemap and webmanifest are taken care of for you
  • You can easily add dynamically rendered pages such as a blog feed and category pages
  • You can apply custom layouts to your pages, either defined by the content path or the layout entry in the content's frontmatter

Dynamic pages

If you're building a blog, you'll probably want a blogfeed. 5sg provides a way to build dynamic pages using your content.

Access your content data at every inpoint

Using the special deriveProps export, every layout and top level .svelte file has access to the meta data of every other file.This means that you can easily create navigation between sibling blog posts, for example.

More nitty-gritty

Project structure

<PROJECT_ROOT>/
├─ .5sg/ # generated by .5sg you can ignore
├─ public/ # the output of the 5sg build process
├─ src/
│  ├─ content/ # This is where your content lives! src/content/foo/bar.(md|svelte) generates public/foo/bar.html
│  ├─ static/ # Unprocessed content. All files are copied to public/static/
│  ├─ <YOUR CUSTOM FILES AND FOLDER>
├─ .gitignore
├─ config.js # Optional config file
├─ package.json

I recommend structuring your <PROJECT_ROOT>/src directory like this, but you do what you want.

<PROJECT_ROOT>/src/
├─ content/ # This is where your content lives! src/content/foo/bar.(md|svelte) generates public/foo/bar.html
├─ static/ # Unprocessed content. All files are copied to public/static/
├─ components / # your svelte components
├─ layouts/ # your page-level layout components
├─ dynamicPages/ # your components for dynamically rendered pages

Recommended .gitignore

node_modules/
public/
.5sg/

Recommended package.json scripts

{
   // ... the rest of your package.json
   "scripts": {
    "build": "5sg",
    "dev": "5sg --serve",
    // ...your test scripts etc. here
  },
}

Partial Hydration

All svelte components are rendered to static html, and, by default, that's where the story ends.

However if you need the component to be hydrated (i.e. interactive), you can use the custom <Hydrate /> component from 5sg. Hydrate accepts two props:

  • component: the component to hydrate
  • and props: the component's props

Example:

<script>
  import Hydrate from "5sg/Hydrate";
  import Count from "../components/Count.svelte";
</script>

<h1>Hello, World!</h1>
<Count name="non-hydrated, non-interactive counter 😢" />
<Hydrate component={Count} props={{ name: "hydrated counter 🤯" }} />

Note that the rendered component will be placed in a <div> which may have layout implications.

Custom markdown preprocessors

By default, markdown files are processed using remark and the following plugins:

remark-highlight.js: to process code fences (you need to add the appropriate global for highlighting to work), remark-gfm: to add github style markdown transformations and remark-gemoji: to transform emojis

If you want to change this, simply define the remarkPlugins property as an array of plugins in config.js

// config.js
import gemoji from 'remark-gemoji';
import footnotes from 'remark-footnotes';
import highlight from 'remark-highlight.js';
import gfm from 'remark-gfm';

export default {
  // ...other config
  remarkPlugins: [highlight, gfm, gemoji, footnotes],
};

if you need to pass options to the plugin you can do so by passing an tuple: [plugin, options]:

import gemoji from 'remark-gemoji';
import highlight from 'remark-highlight.js';
import gfm from 'remark-gfm';
import customPluginWithOptions from './plugin.js';

export default {
  remarkPlugins: [highlight, gfm, gemoji, [customPluginWithOptions, { foo: 'bar' }]],
};

Syntax highlighting

Although we're using remark-highlight.js by default to enable syntax highlighting in code fences, you need to include one of their themes. There's an explorer here, and you can use a cdn to include the styles (see the highlight.js usage page), or download one of the styles from their repo and include it yourself.

Custom Layouts

By default, content is thrown into a plain old html wrapper. In order to give it some style, you'll need to be able to assign it a layout. A layout is simply a svelte file which contains a <slot /> that the transformed content is injected into.

For example, the markdown content

# Hello [world](http://www.example.com) !

plus the layout

<div>
  <nav><a href="/">Home</a></nav>
  <main>
    <slot />
  </main>
</div>

<svelte:head>
  <!-- import global css -->
  <link rel="stylesheet" href="/static/styles/global.css" />
  <!-- highlight.js theme for highlighting code blocks (for blogs and documentation sites, etc.) -->
  <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/10.7.2/styles/default.min.css">
</svelte:head>

<style>
  main {
    width: 1024px;
    margin: 20px auto;
  }
</style>

would result in an html file kindof like this

<html>
  <head>
    <link rel="stylesheet" href="/static/styles/global.css" />
    <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/10.7.2/styles/default.min.css" />
    <style>
      main {
        width: 1024px;
        margin: 20px auto;
      }
    </style>
  </head>
  <body>
    <div>
      <nav><a href="/">Home</a></nav>
      <main>
        <h1>Hello <a href="http://www.example.com">world</a></h1>
      </main>
    </div>
  </body>
</html>

You can define the layouts in the config.js file

// config.js
export default {
  // ...other config
  layouts: { blog: `src/layouts/Blog.svelte`, _: `src/layouts/Page.svelte` },
};

a markdown file will use the _ layout by default, and it will use any other layout based on one of two things:

  1. If its directory relative to src/content matches the layout name. For example, by default all files in src/content/blog will use the blog layout.
  2. If the frontmatter property layout matches the layout name.

Of note:

  • If the frontmatter property is defined, it supercedes the directory-based layout
  • If the frontmatter property layout === false, no layout will be used.
  • The layout name is case insensitive
  • Layouts are not applied to svelte components by default. You can just import the component and use it in your svelte component.

Markdown Frontmatter in Layouts

Layouts receive all properties declared in a markdown file's frontmatter as an object prop called metadata.

Example:

---
title: qui eius qui quisquam!
date: 2021-01-01T20:52:15.045Z
tags:
  - perferendis
  - foo
  - bar
layout: false
---

# Hello world
<script>
  export let metadata = {};
  const { title, date, tags, layout } = metadata;
  // title: string === "qui eius qui quisquam!"
  // date: string === "2021-01-01T20:52:15.045Z"
  // tags: string[] === ["perferendis", "foo", "bar"]
  // layout: boolean === false
  // note, if we had had `layout: blog`, then layout would be a string "blog"
</script>

<slot />

Layout and svelte file deriveProps

Top-level svelte files (i.e. .svelte files in the content folder, layout files, and dynamic page files) have access to the meta data of all content nodes in the project. For the moment the way to access this data is a bit convoluted, and it was done this way as a way to get around atrociously large files in the build process. There may be a better way, and this is one of those things that, we might expect to change in a v1 release.

Here's how it works:

You can export a function called deriveProps from the context="module" script of your page/layout file which takes in all the content node data, and transforms it into props to be injected into the component.

Here's a basic reference of deriveProps:

/**
 * @typedef {Object} NodeMetaEntry
 * @property {Object} metadata the exported frontmatter
 * @property {string} publicPath the publish path with extension
 */

/**
 * @typedef {Object} ContentNode a single block of content in the nodeMap
 * @property {string} facadeModuleId the path of the input file
 * @property {string} fileName the path relative to .5sg/build/bundled for the component
 * @property {string} name the publish path / slug
 * @property {string} publicPath the publish path with extension
 * @property {boolean} isDynamic if true, the ContentNode was created dynamically rather than from a file
 */

/**
 *
 * @param {Object} context the context of the current content node
 * @param {Object<string, NodeMetaEntry>} context.nodeMeta all the content node information, where the key is the path of the content node and the value is the content node meta information
 * @param {ContentNode} context.nodeData the content node information of the current node
 * @returns {Object} the props to be injected into the component
 */
function deriveProps(context) {
  const { nodeMeta = {}, nodeData = {} } = context;

  return {
    //... the injected derived props
  };
}

A basic example

<script context="module">
  export const deriveProps = ({ nodeMeta = {} }) => {
    const numberOfContentNodes = Object.keys(nodeMeta).length;
    return {
      numberOfContentNodes,
    }
  }
</script>

<script>
  // injected from deriveProps
  export let numberOfContentNodes;
</script>

<h1>There are {numberOfContentNodes} in this project</h1>

A more complicated example

<script context="module">
  // layouts/Blog.svelte

  export const deriveProps = ({ nodeMeta = {}, nodeData = { name: "" } }) => {
    // create sibling pages

    // get an array containing only blog nodes, sorted by date
    const blogPages = Object.values(nodeMeta)
      // get all the content nodes in the src/content/blog/ directory
      .filter((node) => node.publicPath.startsWith("blog/"))
      // sort by date
      .sort((a, b) => {
        const dateA = (a.metadata && a.metadata.date) || "";
        const dateB = (b.metadata && b.metadata.date) || "";
        // newest first
        return dateA > dateB ? -1 : 1;
      });

    // get the current node's position in the array
    const currentPath = `${nodeData.publicPath}`;
    const currentIndex = blogPages.findIndex(
      (node) => node.publicPath === currentPath;
    );

    // get the siblings

    // the previous or false
    const prevPost = currentIndex > 0 && blogPages[currentIndex - 1];

    // the next or false
    const nextPost =
      currentIndex < blogPages.length - 1 && blogPages[currentIndex + 1];

    // these will be injected into the component
    return {
      nextPost,
      prevPost,
    };
  };
</script>

<script>
  // these props are injected thanks to deriveProps above
  export let nextPost;
  export let prevPost;
</script>

<article>
  <slot />
  <footer>
    <nav>
      <ul class="sibling-navigation">
        <li>
          {#if prevPost}
            <a href="/{prevPost.publicPath}">← {prevPost.metadata.title}</a>
          {/if}
        </li>
        <li>
          {#if nextPost}
            <a href="/{nextPost.publicPath}">{nextPost.metadata.title} →</a>
          {/if}
        </li>
      </ul>
    </nav>
  </footer>
</article>

Site Meta and SEO

The site meta data from config.js is injected into each top-level svelte component (layout, content page, and dynamically rendered page) as the prop siteMeta.

For example, if in config.js you have

export default {
  siteMeta: {
    name: "My 5sg site!",
  }
}

then in the template Page.svelte or in the content file src/content/index.svelte you could have

<script>
  export let siteMeta = {};
  const { name } = siteMeta;
</script>

<h1>Welcome to {name}</h1>

Additionally, the following siteMeta values are used to create a site.webmanifest file:

name,
short_name,
description,
icons,
theme_color,
background_color,
display,

see the web.dev guide on manifests for more information.

Dynamically built pages using config.getDynamicNodes

In addition to pages rendered based on existing .svelte or .md files, you can create pages dynamically using the getDynamicNodes property of the config object exported by config.js.

getDynamicNodes is a function which receives an array of all non-dynamic node metaData and which must return an array of dynamic page nodes to build.

/**
 * @typedef {Object} NodeMetaEntry
 * @property {Object=} metadata the extracted metadata from the frontmatter (md) or the named export `metadata` from the svelte context="module" script tag
 * @property {string} publicPath the final html path
 */

/**
 * @typedef {Object} RenderablePage
 * @property {Object} props the props to render the component with
 * @property {string} slug the identifier of the page to be rendered (use .dynamic as the extension)
 * @property {string} component the path to the rendering component from the project root
 */

/**
 * Given the nodeMeta, returns the information necessary to render some dynamic pages
 * @param {Array<NodeMetaEntry>} nodes
 * @returns {Array<RenderablePage>}
 */
const getDynamicNodes = (nodes = []) => [];

We could create a simple page like this

//config.js

export default {
  getDynamicNodes: () => [
    // will create a page at path/to/customPage.html using the CustomPage svelte file injected with the props {foo: "bar" }
    {
      props: { foo: 'bar' },
      component: 'src/pages/CustomPage.svelte',
      slug: 'path/to/customPage.dynamic',
    },
  ],
};

This could be useful, for example, for creating a blogfeed

//config.js

export default {
  getDynamicNodes: (nodes = []) => [
    {
      props: { blogPosts: nodes.filter(({ publicPath }) => publicPath.startsWith('/blog')) },
      component: 'src/pages/BlogFeed.svelte',
      slug: 'blog/index.dynamic',
    },
  ],
};

While the example above is possible, it doesn't hold any real advantage over simply using deriveProps.

What would be more useful, for example, is using getDynamicNodes to create a paginated blog feed, where each page contains 10 posts. Here's a somewhat naïve implemenation:

//config.js

export default {
  getDynamicNodes: (nodes = []) => {
    const pages = [];
    const blogPosts = nodes.filter(({ publicPath }) => publicPath.startsWith('/blog'));

    let totalBlogPages = 1;
    let posts = [];

    blogPosts.forEach((post, i) => {
      posts.push(post);
      // every 10 posts, create a new page
      // also create a new page if we're at the end of the array
      if (posts.length === 10 || i === blogPosts.length - 1) {
        pages.push({
          props: { blogPosts: [...posts], currentPage: totalBlogPages, totalNumberOfPosts: blogPosts.length },
          component: 'src/pages/BlogFeed.svelte',
          slug: `blog/${totalBlogPages}.dynamic`,
        });
        // if we're not on the last post, set up the next batch
        if (i < blogPosts.length - 1) {
          posts = [];
          totalBlogPages++;
        }
      }
    });

    // additional props to make pagination easier
    pages.forEach((page, i) => {
      page.props.totalBlogPages = totalBlogPages;
      page.props.nextBlogPageSlug = i === totalBlogPages ? undefined : pages[i + 1].props.slug;
      page.props.prevBlogPageSlug = i > 1 ? pages[i - 1].props.slug : undefined;
    });

    // return the pages to be created
    return pages;
  },
};

In order to help with what we think will be relatively recurrent operations when creating dynamic pages, we've included some helper functions which can be imported from 5sg/helpers

getDynamicSlugFromName

Doc:

/**
 * Formats a name to a dynamic slug which can be universally recognized
 * @param {string} name the page name
 * @returns {string} the dynamic slug
 */

Example:

import { getDynamicSlugFromName } from '5sg/helpers';

const slug = getDynamicSlugFromName('this/is/my/name');
// slug === 'this/is/my/name.dynamic';

paginateNodeCollection

Doc:

/**
 * Given an array of nodes, returns an array paginated nodes to be rendered
 * @param {Array<NodeMetaEntry>} nodes the collection of nodes
 * @param {object} config the pagination config
 * @param {number=} config.perPage the number of nodes to put on a single page 10
 * @param {(i:number)=>string=} config.slugify a function to transform the page number into the slug/path/unique key of the page i => i
 * @param {string=} config.component the component to render each page
 * @returns {Array<RenderablePage>} the paginated node collection
 */

Example:

import { paginateNodeCollection } from '5sg/helpers';

const pages = paginateNodeCollection(
  [
    { metadata: { a: 1 }, publicPath: 'test1.html' },
    { metadata: { a: 2 }, publicPath: 'test2.html' },
    { metadata: { a: 3 }, publicPath: 'test3.html' },
    { metadata: { a: 4 }, publicPath: 'test4.html' },
    { metadata: { a: 5 }, publicPath: 'test5.html' },
  ],
  {
    perPage: 2,
    slugify: (i) => `test/page-${i + 1}.dynamic`,
    component: 'path/to/MyComponent.svelte',
  },
);

// Result
const result = [
  {
    props: {
      nodes: [
        { metadata: { a: 1 }, publicPath: 'test1.html' },
        { metadata: { a: 2 }, publicPath: 'test2.html' },
      ],
      pageNumber: 0,
      numPages: 3,
      pageSlugs: ['test/page-1.dynamic', 'test/page-2.dynamic', 'test/page-3.dynamic'],
    },
    slug: 'test/page-1.dynamic',
    component: 'path/to/MyComponent.svelte',
  },
  {
    props: {
      nodes: [
        { metadata: { a: 3 }, publicPath: 'test3.html' },
        { metadata: { a: 4 }, publicPath: 'test4.html' },
      ],
      pageNumber: 1,
      numPages: 3,
      pageSlugs: ['test/page-1.dynamic', 'test/page-2.dynamic', 'test/page-3.dynamic'],
    },
    slug: 'test/page-2.dynamic',
    component: 'path/to/MyComponent.svelte',
  },
  {
    props: {
      nodes: [{ metadata: { a: 5 }, publicPath: 'test5.html' }],
      pageNumber: 2,
      numPages: 3,
      pageSlugs: ['test/page-1.dynamic', 'test/page-2.dynamic', 'test/page-3.dynamic'],
    },
    slug: 'test/page-3.dynamic',
    component: 'path/to/MyComponent.svelte',
  },
];

sortByNodeDate

Docs:

/**
 * a sort function to sort by date
 * @param {NodeMetaEntry} a
 * @param {NodeMetaEntry} b
 * @returns {-1|1} the sort order
 */

Example:

const nodes = [
  { metadata: { date: '2021-01-03', a: 1 }, publicPath: 'test.html' },
  { metadata: { date: '2021-03-03', a: 2 }, publicPath: 'test2.html' },
  { metadata: { date: '2020-05-30', a: 2 }, publicPath: 'test3.html' },
].sort(sortByNodeDate);

// Result
const result = [
  { metadata: { date: '2021-03-03', a: 2 }, publicPath: 'test2.html' },
  { metadata: { date: '2021-01-03', a: 1 }, publicPath: 'test.html' },
  { metadata: { date: '2020-05-30', a: 2 }, publicPath: 'test3.html' },
];

filterByNodePath

Docs:

/**
 * Creates a function to filter the nodes by their public path
 * @param {string} dir the path to filter by
 * @returns {(NodeMetaEntry)=>boolean}
 */

Example:

const nodes = [
  { metadata: { a: 1 }, publicPath: 'blog/test.html' },
  { metadata: { a: 2 }, publicPath: 'other/test2.html' },
  { metadata: { a: 3 }, publicPath: 'blog/test3.html' },
].filter(filterByNodePath('blog/'));

// Result
const result = [
  { metadata: { a: 1 }, publicPath: 'blog/test.html' },
  { metadata: { a: 3 }, publicPath: 'blog/test3.html' },
];

filterByNodeFrontmatter

Docs:

/**
 * Creates a function to filter the nodes by their frontmatter
 * Returns true if the given key equals the given value OR if the given key contains the given value (if an array)
 * @param {string} key the frontmatter entry key
 * @param {any} val the frontmatter entry value to test against
 * @returns {(NodeMetaEntry)=>boolean}
 */

Example:

// get all nodes with metadata.tags including 'bacon

const nodes = [
  { metadata: { tags: ['bacon', 'eggs', 'cheese'] }, publicPath: 'blog/test.html' },
  { metadata: { tags: ['cheese'] }, publicPath: 'other/test2.html' },
  { metadata: { tags: ['eggs', 'cheese'] }, publicPath: 'blog/test3.html' },
  { metadata: { tags: 'bacon' }, publicPath: 'blog/test4.html' },
].filter(filterByNodeFrontmatter('tags', 'bacon'));

// Result
const result = [
  { metadata: { tags: ['bacon', 'eggs', 'cheese'] }, publicPath: 'blog/test.html' },
  { metadata: { tags: 'bacon' }, publicPath: 'blog/test4.html' },
];

getFrontmatterTerms

Docs:

/**
 * Gathers all the existing values of a given frontmatter entry on a node collection
 * @param {Array<NodeMetaEntry>} nodes the collection of nodes
 * @param {string} key the frontmatter entry key to collect the values of
 * @param {(any)=>any} transform the function to apply to each term (for example a=>a.toLowerCase())
 * @returns {Array}
 */

Example:

const nodes = [
  { metadata: { foo: ['A', 'b', 'c'] } },
  { metadata: { foo: 'd' } },
  { metadata: { foo: ['e', 'C'] } },
  { metadata: { bar: ['lol'] } },
];
const terms = getFrontmatterTerms(nodes, 'foo', (a) => a.toLowerCase());

// result
const result = ['a', 'b', 'c', 'd', 'e'];

groupByFrontmatterTerms

Docs:

/**
 * Groups a node collection by the values in a given frontmatter entry
 * @param {Array<NodeMetaEntry>} nodes the collection of nodes
 * @param {string} key the frontmatter entry key to collect the values of
 * @param {(any)=>any} transform the function to apply to each term (for example a=>a.toLowerCase())
 * @returns {Object<string, Array<NodeMetaEntry>>} the grouped nodes
 */

Example:

const node1 = { metadata: { foo: ['A', 'b', 'c'] } };
const node2 = { metadata: { foo: 'd' } };
const node3 = { metadata: { foo: ['e', 'C'] } };
const node4 = { metadata: { bar: ['lol'] } };
const nodes = [node1, node2, node3, node4];

const groupedNodes = groupByFrontmatterTerms(nodes, 'foo', (a) => a.toLowerCase());

// result
const result = {
  a: [node1],
  b: [node1],
  c: [node1, node3],
  d: [node2],
  e: [node3],
};

For an example of how all of this can be used together take a look at the getDynamicNodes in the blog template: https://github.com/cborchert/5sg-blog-template/blob/main/config.js

Image processing

All .jpg image files which are not in the static folder will be transformed into images which are at most 800w by 400h. We add .avif and .webp file versions, and then we transform all image tags into picture tags with sources.

This will likely be refined before v1, and it will be customizable.

The static folder

The src/static folder is copied directly to public/static without any transformations.

Questions and answers

What's the deal with the tsconfig file ?

This project doesn't use Typescript, yet, mostly because I wanted to avoid a build step. But I nonetheless wanted to make sure that I had a way to implement type-safety. I'm using a weird mash up of js-doc style type declarations along with a ts-config file so that my text editor and linter can catch type errors. It's hacky, but what about this project ISN'T ?

Inspirations

5sg was inspired by Gatsby, ElderJS, 11ty, Grav, and MDSvex. I did extensive research on partial hydration after the version 0 was finished, and would like to thank the developers of ElderJS and 7ty for their implementations which made the most sense to me.

Future plans

Check the project v1 release candidate. Once I have a v1, I truly doubt that I'll do much more work on this other than bugfixes. Hopefully sveltekit gets to a point (and it seems to be rapidly becoming the case), where this project will become obsolete.

Also, the documentation needs A LOT of work. Sorry for anyone who got this far and has no idea what's going on. No promises, but if there's enough demand, I will probably go through and make a few tutorials and/or clean up the documentation

How fast is it?

In my test project which contains 100 images, 994 static pages / posts (md/svelte), and dynamic blogfeed, category, and tags pages resulting in a total of 1124 page total built, the first build takes 30 seconds on my macbook pro, and 3.5 additional seconds when a file is modified.

The experience is pretty subjective, but it seems more or less consistent with what I've seen elsewhere: it's quick.

First build

building
bundling: 10.612s
pruning: 0.007ms
nodeMap: 0.948ms
import: 901.282ms
dynamic: 234.995ms
render: 1.113s
hydrationBundle: 131.178ms
publish: 773.428ms
transform: 16.434s
static: 11.309ms
sitemap: 1.579ms
manifest: 0.201ms
build: 30.216s

After a modification

building
bundling: 2.555s
pruning: 28.73ms
nodeMap: 3.191ms
import: 255.307ms
dynamic: 84.215ms
render: 180.369ms
hydrationBundle: 61.854ms
publish: 246.609ms
transform: 28.427ms
static: 17.949ms
sitemap: 1.734ms
manifest: 0.211ms
build: 3.471s