5sg 中文文档教程
Stupid Simple Svelte Static Site Generator (5SG)
Introduction
5sg 代表stupid simple svelte static site generator。 它是一个正在开发中的静态站点生成器 (SSG),专注于开发的便利性、结构的简单性和交付速度。 它接受 markdown 和 svelte,并输出 html。 我曾计划更改名称,主要是因为法国谷歌大多会出现第 5 周的怀孕结果 (5ème semaine de grossesse),但是????♀️。
Big ideas
Simple build process
- Install 5sg using
npm install -S 5sg
oryarn add 5sg
- Put your content files (
*.md
and.svelte
) in<PROJECT_ROOT>/src/content/
. - 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
您可以使用 degit 安装 5sg 模板
对于基本的入门站点,请使用 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
对于更复杂的博客站点,请使用位于 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) 生成
public/foo/bar.html
Small files and partial hydration
所有生成的 html 都是轻量级的,除非需要,否则客户端不会加载 javascript。
所有图像都经过处理以尽可能使用现代格式。
Customization and automation
从 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
如果您正在构建博客,您可能需要一个 blogfeed。 5sg 提供了一种使用您的内容构建动态页面的方法。
Access your content data at every inpoint
使用特殊的 deriveProps
导出,每个布局和顶级 .svelte
文件都可以访问每个其他文件的元数据。这意味着您可以轻松地在兄弟博客之间创建导航帖子,例如。
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
我建议像这样构建您的
目录,但您可以按自己的意愿行事。
<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
所有 svelte 组件都呈现为静态 html,默认情况下,故事到此结束。
但是,如果您需要组件被水化(即交互),您可以使用 5sg
中的自定义
组件。 Hydrate 接受两个属性:
component
: the component to hydrate- and
props
: the component's props
示例:
<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 ????" }} />
请注意,渲染的组件将放置在
中,这可能会影响布局。Custom markdown preprocessors
默认情况下,markdown 文件使用 remark 和以下插件处理:
remark-highlight.js:处理代码栅栏(你需要添加适当的全局来突出显示), remark-gfm:添加 github 风格的 markdown 转换 和remark-gemoji:转换表情符号
如果你想改变它,只需定义remarkPlugins 属性作为
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],
};
如果您需要将选项传递给插件,您可以通过传递一个元组来实现:[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
虽然我们默认使用 remark-highlight.js
来启用代码围栏中的语法高亮显示,但您需要包含其中一个主题。 此处 有一个资源管理器,您可以使用 cdn 来包含样式(请参阅 highlight.js 使用页面),或从 他们的 repo 并自己包含它。
Custom Layouts
默认情况下,内容被放入一个普通的旧 html 包装器中。 为了给它一些风格,你需要能够为其分配一个布局。 布局只是一个 svelte 文件,其中包含一个
,转换后的内容被注入其中。
例如,markdown 内容
# Hello [world](http://www.example.com) !
加上布局
<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>
会生成这样的 html 文件
<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>
您可以在 config.js
文件中定义布局
// config.js
export default {
// ...other config
layouts: { blog: `src/layouts/Blog.svelte`, _: `src/layouts/Page.svelte` },
};
markdown 文件将使用 _
默认情况下布局,它将使用基于以下两件事之一的任何其他布局:
- If its directory relative to
src/content
matches the layout name. For example, by default all files insrc/content/blog
will use theblog
layout. - If the frontmatter property
layout
matches the layout name.
注意:
- 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
布局接收在 markdown 文件的 frontmatter 中声明的所有属性作为名为 metadata
的对象道具。
示例:
---
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
顶级 svelte 文件(即内容文件夹中的 .svelte 文件、布局文件和动态页面文件)可以访问项目中所有内容节点的元数据。 目前访问这些数据的方式有点复杂,这样做是为了在构建过程中绕过非常大的文件。 可能有更好的方法,这是我们可能希望在 v1 版本中更改的内容之一。
它是这样工作的:
您可以从页面/布局文件的 context="module"
脚本中导出一个名为 deriveProps
的函数,该脚本接收所有内容节点数据,并且将其转换为要注入到组件中的道具。
以下是 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
来自 config.js 的站点元数据作为 prop siteMeta< 注入到每个顶级 svelte 组件(布局、内容页面和动态呈现页面)中/代码>。
例如,如果在 config.js
中,
export default {
siteMeta: {
name: "My 5sg site!",
}
}
那么在模板 Page.svelte
或内容文件 src/content/index.svelte
中你可以
<script>
export let siteMeta = {};
const { name } = siteMeta;
</script>
<h1>Welcome to {name}</h1>
另外,以下 siteMeta 值用于创建 site.webmanifest
文件:
name,
short_name,
description,
icons,
theme_color,
background_color,
display,
请参阅 web.dev清单指南了解更多信息。
Dynamically built pages using config.getDynamicNodes
除了基于现有 .svelte 或 .md 文件呈现的页面之外,您还可以使用 config.js
导出的配置对象的 getDynamicNodes
属性动态创建页面。
getDynamicNodes
是一个函数,它接收所有非动态节点元数据的数组,并且必须返回要构建的动态页面节点数组。
/**
* @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 = []) => [];
我们可以创建一个像这样的简单页面
//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',
},
],
};
这可能很有用,例如,用于创建博客提要
//config.js
export default {
getDynamicNodes: (nodes = []) => [
{
props: { blogPosts: nodes.filter(({ publicPath }) => publicPath.startsWith('/blog')) },
component: 'src/pages/BlogFeed.svelte',
slug: 'blog/index.dynamic',
},
],
};
虽然上面的示例是可能的,但它与简单地使用 deriveProps 相比并没有任何真正的优势。
更有用的是,例如,使用 getDynamicNodes
创建分页博客提要,其中每个页面包含 10 篇文章。 这是一个有点幼稚的实现:
//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;
},
};
为了帮助处理我们认为在创建动态页面时相对重复的操作,我们包含了一些可以从 5sg/helpers
getDynamicSlugFromName
文档导入的辅助函数:
/**
* Formats a name to a dynamic slug which can be universally recognized
* @param {string} name the page name
* @returns {string} the dynamic slug
*/
示例:
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],
};
有关如何将所有这些一起使用的示例,请查看 getDynamicNodes
博客模板:https://github.com/cborchert/5sg-blog-template/blob/main/config.js
Image processing
所有不在静态文件夹中的.jpg图像文件将被转换为最大800w的图像400 小时。 我们添加.avif 和.webp 文件版本,然后我们将所有图像标签转换为带有来源的图片标签。
这可能会在 v1 之前得到改进,并且可以自定义。
The static folder
src/static
文件夹直接复制到 public/static
没有任何转换。
Questions and answers
What's the deal with the tsconfig file ?
这个项目还没有使用 Typescript,主要是因为我想避免构建步骤。 但我仍然想确保我有办法实现类型安全。 我正在使用 js-doc 样式类型声明和 ts-config 文件的奇怪混搭,以便我的文本编辑器和 linter 可以捕获类型错误。 这很骇人听闻,但是这个项目不是吗?
Inspirations
5sg 的灵感来自 Gatsby、ElderJS、11ty、Grav 和 MDSvex。 在版本 0 完成后,我对部分水合进行了广泛的研究,并感谢 ElderJS 和 7ty 的开发人员,他们的实现对我来说最有意义。
Future plans
检查项目 v1 候选版本。 一旦我有了 v1,我真的很怀疑除了错误修复之外我会在这方面做更多的工作。 希望 sveltekit 达到一定程度(并且似乎正在迅速成为这种情况),这个项目将变得过时。
此外,文档需要大量工作。 对于走到这一步却不知道发生了什么的任何人,我深表歉意。 没有承诺,但如果有足够的需求,我可能会完成并制作一些教程和/或清理文档
How fast is it?
在我的测试项目中,它包含 100 张图像、994 个静态页面/帖子 (md/svelte) 和动态 blogfeed,类别和标签页面总共构建了 1124 页,第一次构建在我的 macbook pro 上需要 30 秒,修改文件时需要额外 3.5 秒。
这种体验是相当主观的,但它似乎或多或少与我在其他地方看到的一致:它很快。
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
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
- Install 5sg using
npm install -S 5sg
oryarn add 5sg
- Put your content files (
*.md
and.svelte
) in<PROJECT_ROOT>/src/content/
. - 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:
- If its directory relative to
src/content
matches the layout name. For example, by default all files insrc/content/blog
will use theblog
layout. - 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