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/ exists | MVC app |
config/routes.sl exists | MVC app |
| neither | file 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 folder | 403 |
| a folder containing an index document | that document, by its own rule |
| a folder | generated index, with README.md rendered above it |
*.md, *.markdown | rendered Markdown page |
*.slv, *.erb | executed by the template engine |
/about (no extension) | first of about.md, .html, .html.slv, .slv, .html.erb, .erb |
| any other file | served as-is, with MIME type, ETag and Range |
a file in an --assets root | served as-is — see Extra asset roots |
| nothing matches | 404 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 file | served as-is, with MIME type, ETag and Range |
| a folder | 404 — no generated index, and it is not in the sidebar tree |
*.md | 404 — Markdown is not rendered from an assets root |
*.slv, *.erb | 404 — never executed, and never dumped as source |
/notes for a notes.md | 404 — no extension probe; only exact paths match |
| a dotfile, or a symlink escaping that root | 404 — 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 |
Enter | open the highlighted entry |
Esc | clear 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 jail | every request is canonicalized against the root; symlinks that escape return 403 |
--assets jailed too | each extra root is canonicalized separately, serves files only, and never executes a template |
No .env loading | file mode never reads environment files from the folder |
| No database | no SoliDB connection is configured or opened |
| No controllers | no .sl file is executed; only .slv/.erb templates render |
| File builtins jailed | File.* 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