ESC
Type to search...
S
Soli Docs

Static & Markdown Server

soli serve works on any folder, not just Soli apps. Point it at a plain directory and it becomes a website: files off disk, Markdown rendered as pages, templates executed, and a navigable index for every folder.

soli serve ./notes --dev
# Serving files from /home/you/notes
# Server listening on http://127.0.0.1:5011

No config file, no scaffolding, no build step — the same binary you already have.

How the mode is chosen

Two markers decide it:

Marker Result
app/controllers/ existsMVC app
config/routes.sl existsMVC app
neitherfile mode

A .soli bundle is always an app. Override the detection when you need to:

soli serve .              # detect
soli serve . --static     # force file mode, even inside a Soli app
soli serve . --app        # require an app; fail if the folder is not one

--app keeps the original behaviour, error included. --static is useful for reading a project's own docs/ folder, or for checking what a build output directory actually contains.

What gets served

Request Response
a path segment starting with .404 — dotfiles are invisible
a path escaping the folder403
a folder containing an index documentthat document, by its own rule
a foldergenerated index, with README.md rendered above it
*.md, *.markdownrendered Markdown page
*.slv, *.erbexecuted by the template engine
/about (no extension)first of about.md, .html, .html.slv, .slv, .html.erb, .erb
any other fileserved as-is, with MIME type, ETag and Range
a file in an --assets rootserved as-is — see Extra asset roots
nothing matches404 page naming the nearest folder

GET and HEAD are answered; anything else gets a 405. Requesting a folder without a trailing slash redirects to the slash-terminated URL, so relative links inside a README.md resolve against the right base.

Index documents

index.* means “this is the page for this folder”, so it replaces the generated listing entirely. Looked up in order — index.html, index.htm, index.md, index.html.slv, index.slv, index.html.erb, index.erb — and each is then served by its own rule: HTML as-is, Markdown rendered, templates executed.

A README.md is the opposite: it describes a folder rather than replacing it, so it renders above the listing. Same split as GitHub and every static host.

A folder can hold both, but an index wins outright: with index.md and README.md, only the index renders — the README is still reachable at its own URL, but nothing links to it any more.

Folder pages

A folder with no index document gets a listing of its contents — folders first, then files, with size and last edit for each. Folder listings read their directory directly and always show everything.

Markdown pages

Tables, strikethrough and task lists are enabled — the same converter and the same URL safety policy as .md views inside an app, so a [link](javascript:…) is neutralized to #.

Fenced soli and sl blocks are highlighted server-side by re-lexing them with Soli's own lexer. Other languages render as plain monospace with the fence's info string shown as a label — no guessing, and nothing fetched from a CDN. The page title comes from the document's first # heading.

Every ## and ### gets a slug anchor and appears in an On this page rail on the right, which marks the section you are reading as you scroll. The rail is hidden below 1180px, and a document with fewer than two headings gets none — a one-line table of contents is furniture, not navigation.

Media and other files

Clicking a picture in a listing keeps you in the site: images, video, audio and PDFs open inside the shell, with the breadcrumb, the sidebar and the file's size and type. Text and source files are shown in the page too — .sl and .slv with the same lexer highlighting as a fenced block — up to 512 KB, past which they stay a download. Anything the browser cannot show offers a download link rather than dumping bytes at you.

The raw file stays reachable, which matters: an <img> embedded in a Markdown page needs the picture, not a page about the picture. The two are told apart by what the browser asks for — a click is a navigation (Sec-Fetch-Dest: document), an <img> is a subresource. Tools that send neither header, like curl, get the bytes. Append ?raw to force the file for anything; that is what the viewer's own tag and its download link point at, so it can never recurse into itself.

Templates

.slv and .erb files are executed with the served folder as the views root, so about.html.slv is /about and guides/setup.slv is /guides/setup. Two locals are available: path (the request path) and params (query-string parameters).

<h1>About</h1>
<p>You asked for <%= h(params["name"]) %>.</p>

Rendered without a layout — a plain directory has no layouts/application, and wrapping a standalone page in one nobody wrote would be a surprise. Pull one in with partial() if you want it. Partials, helpers and every other template feature work normally. A template that fails to render returns a 500 page naming the file and the error.

The generated shell is not applied either: a template's output is your HTML, so it has no sidebar, breadcrumb or outline rail. Worth knowing for index.slv, which replaces a folder's page — where index.md arrives wrapped in the shell, index.slv does not.

Templates are code

File mode does not load .env, does not open a database connection and does not run controllers — but a .slv file in the folder is executed. Do not point soli serve at a directory whose contents you do not control.

Extra asset roots

In file mode the served folder is the whole static root — there is no public/ sub-root the way an MVC app has one. So a documentation folder whose pages point at images living next to it, rather than inside it, renders every page and 404s every picture:

www/
├── docs/                           # soli serve www/docs
│   └── blog/scaffolds.md           # <img src="/images/blog/scaffolds.jpg">
└── public/
    └── images/blog/scaffolds.jpg   # …but this is outside the served folder

--assets DIR mounts an extra read-only static root for exactly those paths:

soli serve www/docs --assets www/public
# Serving files from /home/you/www/docs
# Extra assets root: /home/you/www/public

/images/blog/scaffolds.jpg misses in www/docs, so it is looked up in www/public and served from there — with the same MIME type, ETag, 304 and Range handling as any other file. The flag is repeatable, and roots are consulted in the order given:

soli serve ./docs --assets ./shared/images --assets ./brand

The served folder always wins. An assets root is only consulted after the folder itself has failed to match, including its nice-URL extension probe, so nothing you add can shadow a real page.

An assets root holds data, not a second site. Only an existing file answers:

In an assets root Result
a fileserved as-is, with MIME type, ETag and Range
a folder404 — no generated index, and it is not in the sidebar tree
*.md404 — Markdown is not rendered from an assets root
*.slv, *.erb404 — never executed, and never dumped as source
/notes for a notes.md404 — no extension probe; only exact paths match
a dotfile, or a symlink escaping that root404 — each root is jailed like the served one

That is what makes the flag safe to point at a folder you did not audit line by line: it widens what is readable by one directory tree, and never what is executable.

Paths are resolved to absolute at startup, before the server daemonizes, and a typo fails fast with Error: --assets './pubic' is not a directory. An MVC app already serves public/, so the flag does nothing there and says so: Warning: --assets applies to static file mode; ignored for this Soli app.

Under --dev each assets root is watched like the served folder, so editing a picture there reloads the page that embeds it — the startup line (Hot reload: Watching 2 directories) counts them.

The generated pages

Pages are styled in Soli's solar theme, in two states of the same sun: a night palette and a day palette, following prefers-color-scheme with a toggle in the top bar that overrides it. The sidebar is a tree(1) listing drawn with real box-drawing glyphs, and its vertical rail lights up along the chain of folders leading to the page you are on.

Key Action
/focus the filter
move through matches
Enteropen the highlighted entry
Escclear the filter, then leave the field

The sidebar scrolls to centre the file you are reading, so on a deep tree the lit rail is actually in view. It is rendered server-side and every row is a real link, so it works with JavaScript disabled; the script only narrows what is already there. The stylesheet and script are compiled into the binary and served from /__soli/files.css and /__soli/files.js — generated pages make no network request at all, so the mode works offline.

Very large trees are capped at 1000 sidebar entries, and the sidebar says so when it truncates. Folder pages are never truncated.

Live reload & binding

With --dev, editing any file in the folder refreshes the browser — the same live-reload channel Soli apps use. Edit a README.md, save, and the page updates.

soli serve ./docs --dev

File mode binds loopback

Serving a directory you happened to cd into should not publish it to your network without you saying so, so file mode binds 127.0.0.1. Expose it deliberately with SOLI_HOST=0.0.0.0. MVC apps still default to 0.0.0.0, unchanged.

Security

Behaviour Detail
Dotfiles hidden.env, .git/, .ssh/ return 404 and never appear in a listing
Path jailevery request is canonicalized against the root; symlinks that escape return 403
--assets jailed tooeach extra root is canonicalized separately, serves files only, and never executes a template
No .env loadingfile mode never reads environment files from the folder
No databaseno SoliDB connection is configured or opened
No controllersno .sl file is executed; only .slv/.erb templates render
File builtins jailedFile.* and Image.* cannot reach outside the served folder

The dotfile rule returns 404 rather than 403 on purpose: a 403 would confirm the file exists.

Examples

# Read a project's docs folder as a site
soli serve ./docs --dev

# Browse a build output directory
soli serve ./dist

# Read your own notes, with live reload while you write
soli serve ~/notes --dev

# Read a Soli app's docs without booting the app
soli serve ./my_app/docs --static

# …with the images those pages embed, which live in the app's public folder
soli serve ./my_app/docs --static --assets ./my_app/public

# Share on the LAN, deliberately
SOLI_HOST=0.0.0.0 soli serve ./handbook --port 8080