MaNDoS is a CMS server built for creating powerful markdown based wikis, digital gardens, blogs, documentations and more. With a flexible templating system, it allows you to generate beautiful note views, RSS feeds, JSON APIs, and even interactive guestbooks directly from your Markdown folder.
- Quick Start
- Environment Variables
- Template Functions and Variables
- Solo Templates
- Database Tables
- Comparison With Hugo
You can use the default release binaries, build it with go build . command, or just use go run command to start it.
git clone https://github.com/zenarvus/mandos
cd mandos
# If you want to build
go build . && chmod +x ./mandos
# Then you can execute the binary (Don't do that it yet)
./mandos
# Or you can just use go run command (Don't do that it yet)
go -C /optional/path/to/mandos/code run .
Create a folder named mandos in your Markdown folder.
cd /path/to/markdown/folder && mkdir mandos
Create a template named main.html within. This will be the default template used to serve the markdown files if no other one is specified.
Example main.html:
<!DOCTYPE html>
<html>
<head><title>{{.Title}}</title></head>
<body>{{ToHtml .Content}}</body>
</html>
You can create as many templates as you want within this folder. To use them, add a template field to the metadata part of your markdown note and set the value as the name of the template.
- The template named
404.htmlwill be used for 404 pages. If it does not exists, the default template will be used for that purpose. - You can create partials inside
partialsdirectory in the template folder. To use them within other templates, use theIncludefunction like this:{{Include "example-partial.html"}}
Here is an example markdown file with template, tags, date and public metadata fields:
---
template: foo.html
tags: [blog, personal, test]
date: 2077-01-01
public: true
---
# My Markdown Note
Hello there. This node will be served in the foo.html template with given tags and date.
> This is a quoteblock with "test" class, "qb" id and custom styling. Mandos supports custom attributes!
{.test #qb style="background:black;color:white;"}
This line will be excluded if ONLY_PUBLIC=yes <!--exc-->
<!--exc:start-->
This block will be excluded if ONLY_PUBLIC=yes
- Useful if you want to make a page public, but some content is hidden.
<!--exc:end-->
File paths to other notes and links must be an absolute path, considering `MD_FOLDER` as root. For example, if your other note is in /home/user/md-folder/to/other-node.md, you should link it like this:
- [Other node](/to/other-node.md)
> The notes must not contain any space, "?" or "#" characters. Otherwise, things may break.
<script>
console.log("This script will be executed")
</script>The metadata part must be at the top of the markdown file, and must be formatted as YAML, inside
---blocks.
You need to create a folder named static at the root of your Markdown folder. Files in this folder will always be served. This is where you should place your CSS and JavaScript files.
cd /path/to/markdown/folder && mkdir static- Non-markdown files inside other directories are only served if they are linked in a public markdown file.
- Hidden files (filenames starting with dot) are strictly not served. They can be used to store secret data about the server, and can be processed using
WriteFile,ReadFileandDeleteFileoptions.
Mandos uses environment variables for configuration. You can pass them directly like this:
MD_FOLDER=/path/to/markdown/folder INDEX=index.md ONLY_PUBLIC=no MD_TEMPLATES=/path/to/templates/folder SOLO_TEMPLATES=rss.xml,node-list.json,api/comment-guestbook RATE_LIMIT=api/comment-guestbook:600:3 CONTENT_SEARCH=true mandosOr you can create a configuration file:
# /etc/mandos/config.env
MD_FOLDER=/path/to/markdown/folder
INDEX=index.md
ONLY_PUBLIC=no
MD_TEMPLATES=/path/to/templates/folder
SOLO_TEMPLATES=rss.xml,node-list.json
CONTENT_SEARCH=trueThen pass them to Mandos like this:
env -S $(grep -v '^#' /etc/mandos/config.env) mandos12 Environment Variables
- Usage:
MD_FOLDER=/abs/path/to/markdown/folder - Description: The folder to be used to serve the markdown nodes. The markdown files inside
staticormandosfolders, or the markdown files starting with dot will not be served regardless of theONLY_PUBLICvalue. - Default: Empty string. Will throw an error if not specified.
- Usage:
INDEX=index.md - Description: The file to be served at the root path of the server (
/). The default is - Default:
index.md
- Usage:
ONLY_PUBLIC=no - Description: Serve the every non-hidden markdown file in the directory and consider all of them as
public. Remove it if you just want to serve thepublicnodes. A markdown file is considered public if itspublicmetadata field is set totrue. Every non-markdown file a public markdown file links to will also be served. However, private markdown files a public one links to will not be served. - Default:
yes
- Usage:
NO_ATTACHMENT_CHECK=true - Description: By default, Mandos serves non-markdown files only if they have an inlink from markdown files. Enabling this option disables this check and can improve attachment serving performance.
- Default: Empty string. Mandos checks if a non-markdown file contain an inlink before serving.
- Usage:
MD_TEMPLATES=/path/to/templates/folder - Description: Set a custom template folder if you do not want to use the default
mandosat the root ofMD_FOLDER. - Default:
mandosfolder at the root ofMD_FOLDER.
- Usage:
SOLO_TEMPLATES=rss.xml,node-list.json - Description: The relative paths of the files in
MD_FOLDER, separated with commas. These files will be used as solo templates. - Default: No solo template.
- Usage:
CONTENT_SEARCH=true - Description: Enable searching through file contents using SQLite FTS5 virtual table.
- Default:
false, No index will be generated, resulting in smaller database file sizes.
- Usage:
CACHE_FOLDER=/abs/path/to/cache/folder - Description: The location the SQLite database and other Mandos related files will be created.
- Default:
mandosdirectory inside user's default cache folder.
- Usage:
CERT=/abs/path/to/cert/file KEY=/abs/path/to/key/file - Description: Used to run the server with TLS encryption.
- Default: Ignored. The server will run in HTTP mode.
- Usage:
BEHIND_PROXY=true - Description: If it's set to true, the server will look at the
X-Forwarded-Forheader for the IP addresses. Useful if you are behind a trusted gateway server (e.g. a load balancer). Do not forget to set this header inside your proxy server. - Default: Not behind a proxy.
- Usage:
RATE_LIMIT=!md:80:80,!att:80:80,solotemp1.json:80:45,solotemp2.txt:20:45 - Description: Comma separated list of items for rate limiting endpoints. Each item is separated to three parts. The first one is can be
!md,!attor the solo templates given inSOLO_TEMPLATES.!mdis for all the markdown files and!attis for all attachments and static files. The second part is the expiration time of the limit in seconds, and the last part is the maximum number of recent connections during "expiration seconds" before sending a 429 response. - Default: No rate limit is applied.
- Warning: Set
BEHIND_PROXYif you are behind an another server.
- Usage:
LOGGING=true - Description: Enable request logging and print IP addresses with access paths to STDOUT.
- Default: No logging.
While the template functions can be used from any template, the scope of the variables differs.
11 Core Variables
- Scope: Both in markdown and solo templates.
- Description: The current time in Unix epoch.
- Type:
int64
- Scope: Both in markdown and solo templates.
- Description: See Fiber CTX. It is not recommended to call
c.Sendor anything that writes a response to the requester. You should use it carefully. - Type:
*fiber.Ctx
- Scope: Both in markdown and solo templates.
- Description: The full URL path including the query. Example:
https://example.com/search?q=something - Type:
string
- Scope: Only in markdown templates.
- Description: The metadata part of the markdown file, excluding the title and date. The metadata must be a string or array of strings.
- Type:
map[string]any
Usage: WIP
- You can use
{{index .Params "key"}}to access a value in it.
- Scope: Only in markdown templates.
- Description: The absolute path of the markdown file, considering the
MD_FOLDERas root. - Type:
string
- Scope: Only in markdown templates.
- Description: The first H1 heading of the Markdown file, or the
titlefield in the document's metadata. - Type:
string
- Scope: Only in markdown templates.
- Description: The Unix epoch date of the "time-aware" node. A node can be made time-aware by adding a
datefield in its metadata with a value formatted inyyyy-mm-dd. - Type:
int64
- Scope: Only in markdown templates.
- Description: The raw content of the Markdown file, excluding the metadata part.
- Type:
string
- Scope: Only in markdown templates.
- Description: The
.Filevalues of the markdown files this one has links to. - Type:
[]string
- Scope: Only in markdown templates.
- Description: The list of non-markdown files this file has links to.
- Type:
[]string
27 Core Functions
- Scope: Both in markdown and solo templates.
- Description: Add second parameter to the first.
- Return:
int - Usage:
{{Add 1 3}} (Result: 3)
- Scope: Both in markdown and solo templates.
- Description: Subtract the second parameter from first.
- Return:
int - Usage:
{{Sub 5 3}} (Result: 2)
- Scope: Both in markdown and solo templates.
- Description: Convert
anygolang type to string usingfmt.Sprint - Return:
string - Usage:
{{ToStr 100}} (Result: "100")
- Scope: Both in markdown and solo templates.
- Description: Convert a string to integer. The first returned value is the converted integer. If the conversation fails, it returns
-9223372036854775808which is equal to{{.InvalidInt64}} - Return:
int64 - Usage:
{{ToInt "100"}} (Result: 100)
- Scope: Both in markdown and solo templates.
- Description: Check if an int64's value invalid. It returns
trueif the integer not equals to-9223372036854775808. - Return:
bool - Usage:
{{IsInt64Valid 100}} (Result: true)
- Scope: Both in markdown and solo templates.
- Description: Convert the given Markdown string to HTML using Goldmark.
- Return:
string - Usage:
{{ToHtml "# Hello"}} (Result: "<h1>Hello</h1>")
- Scope: Both in markdown and solo templates.
- Description: Replace the characters in the first parameter with the given "old" and "new" pairs.
- Return:
string - Usage:
{{ReplaceStr "Hello" "He" "Fe" "o" "a"}} (Result: "Fella")
- Scope: Both in markdown and solo templates.
- Description: Check if the first parameter contains the second string
- Return:
bool - Usage:
{{Contains "Hello" "He"}} (Result: true)
- Scope: Both in markdown and solo templates.
- Description: Check if the first parameter contains the second parameter as prefix.
- Return:
bool - Usage:
{{HasPrefix "Hello" "He"}} (Result: true)
- Scope: Both in markdown and solo templates.
- Description: Check if the first parameter contains the second parameter as suffix.
- Return:
bool - Usage:
{{HasSuffix "Hello" "lo"}} (Result: true)
- Scope: Both in markdown and solo templates.
- Description: Get the filename from the given filepath.
- Return:
string - Usage:
{{FilePathBase "/this/is/a/long/path/.access-tokens"}} (Result: ".access-tokens")
- Scope: Both in markdown and solo templates.
- Description: Merge given parameters to one path string and sanitize them.
- Return:
string - Usage:
{{FilePathJoin "/sub" "folder", "test.txt"}} (Result: "/sub/folder/text.txt")
- Scope: Both in markdown and solo templates.
- Description: Slugify the given string.
- Return:
string - Usage:
{{FilePathJoin "This Is A Title"}} (Result: "this-is-a-title")
- Scope: Both in markdown and solo templates.
- Description: Split the first parameter to individual items using the second parameter, then split these items to key-value pairs using the last parameter. If the keys are the same in different items combine their values into one
"key":["val1","val2"]map item. - Return:
map[string][]string - Usage:
{{DoubleSplitMap "key1=1||key1=2||key2=2||key3=3" "||" "="}} (Result: {"key1":["1",2], "key2":["2"], "key3":["3"]})
- Scope: Both in markdown and solo templates.
- Description: Split the first parameter using the second parameter.
- Return:
[]string - Usage:
{{DoubleSplitMap "key1=1||key1=2||key2=2||key3=3" "||"}} (Result: ["key1=1","key1=2","key2=2","key3=3"])
- Scope: Both in markdown and solo templates.
- Description: Convert given arguments in any type into a slice of any.
- Return:
[]any - Usage:
{{AnyArr "hello" 5}} (Result: ["hello", 5])
- Scope: Both in markdown and solo templates.
- Description: Golang
url.Parsefunction without returning an error. - Return:
*url.URL - Usage:
{{(UrlParse "https://example.com/search?q=test").Query.Get "q"}} (Result: "test")
- Scope: Both in markdown and solo templates.
- Description: Convert an Unix epoch time to given date string format.
- Return:
string - Usage:
{{FormatDateInt 1766917944 "Mon, 02 Jan 2006 15:04:05 GMT"}}
- Scope: Both in markdown and solo templates.
- Description: Returns the value of the given environment variable, if it exists.
- Return:
string - Usage:
{{GetEnv "ADMIN_PASS"}} (Example-Result: "123456789")
- Scope: Both in markdown and solo templates.
- Description: Include a partial to the template by passing the partial name as a string. (Do not use this function in the partials)
- Return:
string - Usage:
{{Include "partial.html"}}
- Scope: Both in markdown and solo templates.
- Description: Get the raw markdown content of the given node, excluding the metadata and excluded lines.
- Return:
string - Usage:
{{GetNodeContent "/index.md"}}
- Scope: Both in markdown and solo templates.
- Description: First parameter must be the node content, and the second parameter must be the FTS5 match query used to match that node. The last parameter is the window size (characters). It highlights the matched part with the given window size in the line, and returns it.
- Return:
string - Usage: See the
search.jsonexample below.
- Scope: Both in markdown and solo templates.
- Description: Make a SQLite query using the first parameter with values in second parameter. Return a slice of maps where keys are the selected columns and values are the values of these columns. It returns nothing if its not a SELECT query. You can iterate through them using
{{range (Query...)}}...{{end}}. - Return:
[]map[string]any - Warning: Always pass the values using the second parameter.
- Usage: See the examples below.
- Scope: Both in markdown and solo templates.
- Description: Check if a folder or file exists in
MD_FOLDER. The given parameter must be the absolute file location, consideringMD_FOLDERas root. It returnstrueif file exists. - Return:
bool - Usage:
{{$exists := FileExists "/guestbook.txt"}} (Result: true)
- Scope: Both in markdown and solo templates.
- Description: Get the real content of any file in
MD_FOLDER. The given parameter must be the absolute file location, consideringMD_FOLDERas root. It returns nothing if file is empty or does not exists. IfWriteFileruns at the same time, it waits for write before reading. - Return:
string - Usage:
{{$content := ReadFile "/guestbook.txt"}}
- Scope: Both in markdown and solo templates.
- Description: Write to any file in
MD_FOLDERand create if it does not exists. The first parameter must be the absolute file location, consideringMD_FOLDERas root. It creates the sub folders if they do not exist. The second parameter must be the new file content. It returnstrueif the write is successful. Use withReadFileinstead ofGetNodeContentif you are going to do a read & write operation.GetNodeContentdoes not wait for write to finish and it can result in corrupted files. - Return:
bool - Usage:
{{WriteFile "/guestbook.txt" ("New line at the top\n" + $content)}}
- Scope: Both in markdown and solo templates.
- Description: Delete any file or empty folder in
MD_FOLDER. The given parameter must be the absolute file location, consideringMD_FOLDERas root. It returnstrueif the delete is successful. - Return:
bool - Usage:
{{DeleteFile "/old_guestbook.txt"}}
A solo template is a non-markdown file that can execute the template functions and serve the results to GET and POST requests. These files can be used to, for example, generate an RSS feed, create a comment or delete a markdown file.
- The solo templates needs to be outside of the
staticfolder, and they cannot be a markdown file. - If the request method is POST, you can access to the form values in solo templates.
An example nodes.json file to create a node list in json format. It can allow you to create cool useless graphs like the ones in Obsidian.
{{- $queryStr := `WITH TargetNodes AS (
SELECT n.file, n.date, n.title
FROM nodes AS n
), TagsAgg AS (
SELECT p."from", GROUP_CONCAT(p.key || '=' || p.value, '||') as params_str
FROM params AS p
INNER JOIN TargetNodes tn ON p."from" = tn.file AND p.key = "tags"
GROUP BY p."from"
), OutlinksAgg AS (
SELECT o."from", GROUP_CONCAT(o."to", '||') as outlinks_str
FROM outlinks AS o
INNER JOIN TargetNodes tn ON o."from" = tn.file
GROUP BY o."from"
)
SELECT tn.file, tn.date, tn.title, par.params_str, ol.outlinks_str
FROM TargetNodes AS tn
LEFT JOIN TagsAgg AS par ON tn.file = par."from" LEFT JOIN OutlinksAgg AS ol ON tn.file = ol."from";` -}}
{{- $results := (Query $queryStr (AnyArr)) -}}
{{- $listLen := len $results -}}
[
{{- range $i, $v := $results -}}
{{- $outlinks := (Split $v.outlinks_str "||") -}}
{{- $olLen := len $outlinks -}}
{{- $params := (DoubleSplitMap $v.tags_str "||" "=") -}}
{{- $tags := $params.tags -}}
{
"file":"{{$v.file -}}",
"title":"{{ReplaceStr $v.title `"` `\"` }}",
"tags":[
{{- range $tagi,$tag := $tags -}} "#{{$tag}}"
{{- if ne (Add $tagi 1) (len $tags)}},{{end -}}
{{- end -}}
],
"outlinks":[
{{- range $oli, $olv := $outlinks -}} "{{$olv}}"
{{- if ne (Add $oli 1) $olLen }},{{end -}}
{{- end -}}
]
}{{- if ne (Add $i 1) $listLen}},{{end -}}
{{- end -}}
]
An example rss.xml file to create an RSS feed.
{{- $queryStr := `SELECT file, date, title FROM nodes WHERE date > 0 ORDER BY date DESC ;` -}}
<?xml version="1.0" encoding="UTF-8" ?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom"><channel>
<title>Zenarvus</title>
<link>https://v.oid.sh/rss.xml</link>
<description>My second brain on the web.</description>
{{- range (Query $queryStr (AnyArr)) -}}
<item>
<title>{{.title}}</title>
<link>https://v.oid.sh{{.file}}</link>
<pubDate>{{FormatDateInt .date "Mon, 02 Jan 2006 15:04:05 GMT"}}</pubDate>
</item>
{{- end -}}
</channel></rss>
An example search.json to search file contents based on the value of the query parameter q. (CONTENT_SEARCH must be true)
{{- $queryStr := `SELECT n.file, n.date, n.title
FROM nodes n JOIN nodes_fts f ON n.id = f.rowid
WHERE nodes_fts MATCH ? ORDER BY bm25(nodes_fts, 10.0, 1.0) ASC LIMIT 20;` -}}
{{- $urlQ := (UrlParse .Url).Query.Get `q` -}}
{{- $results := (Query $queryStr (AnyArr $urlQ)) -}}
{{- $listLen := len $results -}}
[{{- range $i, $v := $results -}}
{"file":"{{$v.file -}}",
"title":"{{ReplaceStr $v.title `"` `\"` }}",
"content":"{{ReplaceStr (GetContentMatch (GetNodeContent $v.file) $urlQ 30) `\` `\\` `"` `\"` "\n" `\n` "\t" `\t`}}"
}{{if ne (Add $i 1) $listLen}},{{end -}}
{{- end -}}]
An example api/comment-guestbook file to append a text to the guestbook.txt file.
{{- $oldContent := ReadFile "/guestbook.txt" -}}
{{- $author := ReplaceStr (.Ctx.FormValue "author") "\n" `\n` -}}
{{- $comment := ReplaceStr (.Ctx.FormValue "comment") "\n" `\n` -}}
{{- if and (ne $comment "") (ne $author "") -}}
{{- WriteFile "/guestbook.txt" (printf "%s: %s\n%s\n\n%s" (FormatDateInt .Now "02-Jan-2006") $author $comment $oldContent) -}}
{{- else -}}
{{ .Ctx.Status 400 }}
Comment or author name is not given.
{{- end -}}
- To prevent spam, set a rate limiter for this endpoint like:
RATE_LIMIT=api/comment-guestbook:600:3. It only allows 3 requests within 600 seconds (5 minutes).
The SQLite database contains five tables: nodes, outlinks, attachments, params and nodes_fts. It is possible to query nodes using these tables.
CREATE TABLE IF NOT EXISTS nodes (
id INTEGER PRIMARY KEY AUTOINCREMENT,
file TEXT UNIQUE,
mtime INTEGER NOT NULL,
date INTEGER,
title TEXT
);
CREATE INDEX IF NOT EXISTS idx_node_file ON nodes(file);
CREATE INDEX IF NOT EXISTS idx_node_date ON nodes(date);
CREATE TABLE IF NOT EXISTS outlinks (
"from" TEXT NOT NULL,
"to" TEXT NOT NULL,
PRIMARY KEY ("from", "to"),
FOREIGN KEY ("from") REFERENCES nodes(file) ON DELETE CASCADE
) WITHOUT ROWID;
CREATE INDEX IF NOT EXISTS idx_outlink_to ON outlinks("to");
CREATE TABLE IF NOT EXISTS attachments (
"from" TEXT NOT NULL,
file TEXT NOT NULL,
PRIMARY KEY ("from", file)
FOREIGN KEY ("from") REFERENCES nodes(file) ON DELETE CASCADE
) WITHOUT ROWID;
CREATE INDEX IF NOT EXISTS idx_attachment_file ON attachments(file);
CREATE TABLE IF NOT EXISTS params (
"from" TEXT NOT NULL,
key TEXT NOT NULL,
value TEXT NOT NULL,
PRIMARY KEY ("from", key, value)
FOREIGN KEY ("from") REFERENCES nodes(file) ON DELETE CASCADE
) WITHOUT ROWID;
CREATE INDEX IF NOT EXISTS idx_params_key_val_from ON params(key, value, "from");
CREATE INDEX IF NOT EXISTS idx_params_from ON params("from");
- If a parameter has multiple values, the values are saved in different rows with the same key.
CREATE VIRTUAL TABLE IF NOT EXISTS nodes_fts USING fts5(
title, content,
content='', contentless_delete=1,
tokenize="unicode61 remove_diacritics 2 tokenchars '#'"
);
- Only created if
CONTENT_SEARCHis true. Otherwise, does not exists.
No Full Rebuilds
- Hugo: As a static site generator, content changes can require a full rebuild, and build times will increase as the website grows.
- Mandos: Operates as a live server. Content changes are automatically indexed and served in real time. It utilizes incremental builds, eliminating the need for a full rebuild step. This is more efficient than Hugo for large and/or dynamic websites.
Built-In Dynamic Querying
- Hugo: Only static content. External tools are required for dynamic queries and searches.
- Mandos: Built on SQLite with optional FTS5 (Full-Text Search) support. This allows for complex SQL queries and robust content searching directly within the templates.
File-System Overhead
- Hugo: Generates a full static copy of the original content, assets, and templates. Consequently, the required storage space is roughly double the size of the original source content.
- Mandos: Serves the original files directly and only copies metadata and content indexes for searching. The additional space required is usually significantly smaller than the original content.
Serving Performance
- Hugo: Files are pre-rendered. The server only needs to deliver static files from storage. When combined with caching, it becomes really fast.
- Mandos: Markdown is parsed on-the-fly, and the content needs a small amount of processing before being served every time. However, LRU and TTL caches are utilized and the difference in terms of performance is minimal with Hugo.
You should choose Hugo if you are unable to run a custom server, your content is mostly static, or you just feel like it. If the content constantly changes, it is better to choose Mandos.