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 π€·ββοΈ.
npm install -S 5sg
or yarn add 5sg
*.md
and .svelte
) in <PROJECT_ROOT>/src/content/
.5sg
to build to the <PROJECT_ROOT>/public
directory5sg --serve
to build to the <PROJECT_ROOT>/public
directory and serve on http://localhost:3221You 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
src/content/foo/bar.(md|svelte)
generates public/foo/bar.html
All generated html is feather-weight and the client loads no javascript unless needed.
All images are processed to use modern formats where possible.
Customize everything from config.js
layout
entry in the content's frontmatterIf you're building a blog, you'll probably want a blogfeed. 5sg provides a way to build dynamic pages using your content.
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.
<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
.gitignore
node_modules/
public/
.5sg/
package.json
scripts{
// ... the rest of your package.json
"scripts": {
"build": "5sg",
"dev": "5sg --serve",
// ...your test scripts etc. here
},
}
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 hydrateprops
: the component's propsExample:
<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.
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' }]],
};
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.
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:
src/content
matches the layout name. For example, by default all files in src/content/blog
will use the blog
layout.layout
matches the layout name.Of note:
layout
=== false, no layout will be used.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 />
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
};
}
<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>
<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>
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.
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
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';
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',
},
];
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' },
];
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' },
];
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' },
];
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'];
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
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 src/static
folder is copied directly to public/static
without any transformations.
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 ?
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.
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
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.
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
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