The MDX reader.
Point it at a folder. Get a site. VertΕ β to turn the page.
Verto is to MDX what Obsidian is to Markdown β a reader that treats a folder of files as a first-class library.
Drop any collection of .mdx (or .md) files into content/ and Verto
turns the folder into a navigable, statically-rendered site: file-tree
sidebar, table of contents, breadcrumbs, prev/next, and a rich set of
MDX block components β all pre-rendered at build time.
Verto is a reader, not a CMS and not an editor. There is no database, no admin UI, no required frontmatter. Your files are the source of truth; the file system is the schema. If you can write MDX in any editor β VS Code, Obsidian, Cursor, vim β Verto can read it.
Markdown is a great format for plain text. MDX is what you reach for the moment your notes want to do something β embed a callout, lay out a comparison table, sketch a diagram, attach a comment, drop in an interactive component. Verto is built around that need:
- MDX is native. Components are first-class;
.mdis treated as a strict subset that just works. - A built-in component library. Callouts, Toggles, Bookmarks, Figures, Task Lists, code blocks with line highlighting, inline-comment popovers β ready out of the box, no imports required.
- Unknown components don't crash. Third-party MDX with custom JSX renders a friendly placeholder instead of throwing β paste from anywhere.
- Static-first. Every page is pre-rendered. Zero runtime, deploy anywhere.
| Obsidian (Markdown) | Verto (MDX) | |
|---|---|---|
| Source of truth | A folder (vault) of .md |
A folder (content/) of .mdx / .md |
| Schema | None β files and folders | None β files and folders |
| Extensibility | Plugins | MDX components |
| Reading UI | Built-in reader pane | Statically-rendered Next.js site |
| Lock-in | None β plain text on disk | None β plain text on disk |
| Output | Local app | A site you can host anywhere |
- π§© 10+ built-in block components β Callout, Toggle, BookmarkCard, Figure, TaskList, Table, BlockquoteStyled, CodeBlock, and more β no imports required
- π¨ Shiki syntax highlighting β dual light/dark themes, rendered at build time, zero client JS
- π¬ Inline comments β
[^c-N]footnotes become highlighted text with click-to-reveal popovers β demo - π‘οΈ Unknown-component fallback β MDX from anywhere won't crash; unmapped JSX tags render as a friendly placeholder
- π
.mdworks too β same pipeline, same components, same output
- π Auto file-tree sidebar β recursively scans
content/, collapsible directories, current-file highlight - πͺΆ Optional frontmatter β title falls back to first H1 then filename; description to the first paragraph; sort by
order, date, then title - π§ Breadcrumbs + prev/next β derived from the file tree's reading order
- π Directory index pages β landing on a folder lists its contents (or renders
_index.mdif present) - π Surgical overrides β optional
content/navigation.jsonto rename, sort, or hide entries without renaming files
- π Reading-progress bar β thin indicator below the navbar, updates on scroll
- π Dark mode β CSS variables, no-flash script, persists preference
- β‘ Pre-rendered at build time β every page statically generated, ready for Vercel
- π± Responsive β mobile-first layout with adaptive breakpoints
- π¦ Node.js 18.17 or higher
git clone https://github.com/tsaiggo/verto.git
cd verto
npm install
npm run devSite runs at http://localhost:3000.
| Command | Description |
|---|---|
npm run dev |
Dev server with hot reload |
npm run build |
Static production build |
npm start |
Serve the production build |
npm run lint |
ESLint |
npm test |
Vitest suite |
npx vercelStatic generation by default. No config needed.
verto/
βββ app/
β βββ page.tsx β Reader home (sections + recently updated)
β βββ read/[[...path]]/ β Unified document route (your Library, /read/*)
β βββ help/[[...path]]/ β Bundled Help docs route (/help/*)
β βββ layout.tsx β Root layout (Navbar + Footer + theme script)
βββ components/
β βββ reader/ β FileTree, Breadcrumb, PrevNext, ReadingProgress, DirectoryIndex
β βββ layout/ β Navbar, TableOfContents, Footer
β βββ mdx/ β Block components + UnknownComponent fallback
β βββ ui/ β ThemeToggle, MobileMenu, selection-share helpers
βββ content/ β Your vault β drop .mdx / .md here, any depth
β βββ navigation.json β Optional sort / hide / rename overrides
βββ help-content/ β Bundled product docs (the Help section)
β βββ navigation.json β Help-only sort / hide / rename overrides
βββ lib/
βββ content-source/ β Pluggable storage backend (local, github, onedrive)
β βββ types.ts β ContentSource / RawFileEntry / ContentNode types
β βββ tree.ts β Source-agnostic tree builder + slug resolvers
β βββ local.ts β Filesystem source (default)
β βββ github.ts β GitHub repo source (Git Trees API)
β βββ onedrive.ts β OneDrive source (Microsoft Graph)
β βββ index.ts β Source selector (VERTO_CONTENT_SOURCE)
βββ content-source.ts β Re-export bridge (legacy import path)
βββ help-source.ts β Help tree API (content-source pinned to help-content/)
βββ mdx.ts β Compile + render pipeline (Shiki, GFM, inline-comments)
βββ plugins/ β remark/rehype-inline-comments
βββ shiki.ts β Lazy-loaded highlighter
βββ toc.ts β Heading extraction for the right sidebar
βββ format.ts β Date formatter
Drop a .mdx or .md file anywhere under content/. The URL mirrors the
file path:
| File | URL |
|---|---|
content/notes/quick-thought.md |
/read/notes/quick-thought |
content/blog/2026/launch.mdx |
/read/blog/2026/launch |
content/projects/_index.md |
/read/projects |
---
title: My Document
description: Shown in directory listings and meta tags.
date: "2026-05-14"
author: Me
tags: ["draft", "ideas"]
order: 1
hidden: false
---
Your content here.When a field is omitted Verto fills it in:
| Field | Fallback |
|---|---|
title |
First # H1 heading β humanized filename |
description |
First non-heading paragraph (truncated) |
date |
File modification time (shown as "Updated β¦") |
order |
Date β alphabetical |
A file named _index.md, index.md, or README.md inside a directory
becomes that directory's landing page. Without one, Verto renders an
auto-generated index listing the directory's children.
Use this file only when you want to override what the file system would do naturally:
{
"overrides": {
"showcase": { "title": "Showcase", "order": 1 },
"drafts": { "hidden": true },
"notes/old-name": { "title": "New Name" }
}
}Keys are slug paths relative to content/, without the file extension.
| Component | Description |
|---|---|
Callout |
Admonitions: info, warning, tip |
Toggle |
Collapsible content block |
BookmarkCard |
Link preview card with title + description |
Figure |
Image with caption |
DiagramPlaceholder |
Placeholder for diagrams |
TaskList |
Checkbox task lists |
Table |
Styled Markdown tables |
BlockquoteStyled |
Styled blockquotes |
CodeBlock |
Shiki-highlighted code with dual themes |
PackageInstall |
npm / pnpm / yarn / bun install tabs with copy button |
InlineCode |
Styled inline code spans |
UnknownComponent |
Placeholder shown when a doc references an unmapped JSX component |
The signature feature, repurposed for the reader: turn footnote-style annotations into floating popovers as you read.
This took real effort[^c-1] to get right.
[^c-1]: Three days of SSR debugging. Worth it.[^c-N]β highlighted text + popover in Verto[^N]β regular footnote (still works)- Degrades to standard footnotes on GitHub β no content lost either way
/blog/* is now a permanent (308) redirect to /read/blog/*, and content
under content/blog/ continues to work unchanged. Verto's own bundled
documentation has moved out of the Library into the dedicated Help
section: the old /docs/* routes now redirect to
/help.
Verto ships its own product documentation β the pages that explain Verto
itself β as a built-in Help section, reachable from the left rail and
served under /help/*. It is intentionally kept separate from your Library:
- Always available. Help is sourced from the bundled
help-content/directory, not fromcontent/. Pointing your Library at a GitHub repo or a OneDrive folder (see Content Sources) swaps/read/*only β/help/*stays put. - Same engine. Help reuses the exact tree builder, MDX pipeline and block components as the Library, so authoring a Help page is identical to authoring any other document.
- Its own overrides.
help-content/navigation.jsoncontrols Help ordering, titles and visibility independently ofcontent/navigation.json.
Internally, Help is a second ContentSource tree pinned to help-content/
(lib/help-source.ts). Because that source is created
with an explicit root directory, it never follows VERTO_LOCAL_DIR /
VERTO_CONTENT_SOURCE, and every Help href is rendered under /help.
Verto resolves the readable content behind /read/* through a pluggable
ContentSource abstraction. By default it walks the local ./content
directory, but the same site can be pointed at a remote vault β a GitHub
repository or a OneDrive folder β by setting environment variables. See
.env.example for the full list.
| Source | When to use | Required env |
|---|---|---|
local (default) |
Files in a local folder; static site, no network | none (VERTO_LOCAL_DIR optional) |
github |
Vault lives in a GitHub repo (public or private) | VERTO_GITHUB_REPO |
onedrive |
Vault lives in OneDrive (shared link or private) | VERTO_ONEDRIVE_SHARE_URL or VERTO_ONEDRIVE_REFRESH_TOKEN (+ client id/secret) |
Pick the source with VERTO_CONTENT_SOURCE (local | github | onedrive).
The selected source is used at build time, so changing content still
requires a rebuild β Verto remains a statically-rendered reader.
VERTO_CONTENT_SOURCE=local
VERTO_LOCAL_DIR=content # optional; folder to read .md/.mdx fromVERTO_LOCAL_DIR points the reader at any folder on disk. It may be
absolute or relative to the project root; when unset it defaults to the
bundled ./content directory.
In the desktop app, the Connect source page offers a Local Files
provider with a Choose folder⦠button that opens the native folder
picker, so you can browse to the directory you want to read instead of
editing env vars by hand. Saving a folder refreshes the desktop Library rail
with the .md / .mdx files found there. The document route is still part of
the static export, so files that were not present at build time may need a
future runtime reader before their contents can be opened from the rail.
VERTO_CONTENT_SOURCE=github
VERTO_GITHUB_REPO=owner/repo
VERTO_GITHUB_BRANCH=main # optional, defaults to "main"
VERTO_GITHUB_PATH=content # optional sub-directory in the repo
VERTO_GITHUB_TOKEN=ghp_xxx # optional; required for private reposA single Git Trees API call enumerates the whole repo, then individual
files are fetched as blobs on demand. Without a token the unauthenticated
rate limit is 60 requests/hour β set VERTO_GITHUB_TOKEN (a fine-grained
PAT with Contents: read is enough) to raise it to 5000/h.
Two operating modes β share-URL mode is the simplest:
VERTO_CONTENT_SOURCE=onedrive
VERTO_ONEDRIVE_SHARE_URL=https://1drv.ms/u/s!...
VERTO_ONEDRIVE_PATH=content # optional sub-folder inside the shared itemAny user with the share link can read the folder, so no OAuth is needed.
Verto encodes the share URL into Microsoft Graph's u!β¦ share-id scheme
and walks the folder via /shares/{id}/driveItem.
For private content register a Microsoft Entra (Azure AD) app, grant
it Files.Read + offline_access, complete a one-off auth dance to get
a refresh token, and configure:
VERTO_CONTENT_SOURCE=onedrive
VERTO_ONEDRIVE_TENANT=common # or "consumers" / a tenant GUID
VERTO_ONEDRIVE_CLIENT_ID=...
VERTO_ONEDRIVE_CLIENT_SECRET=...
VERTO_ONEDRIVE_REFRESH_TOKEN=...
VERTO_ONEDRIVE_PATH=contentTokens are refreshed automatically each build. The implementation
respects Graph @odata.nextLink pagination and backs off on 429 /
Retry-After.
- Remote sources don't reliably surface a per-file modification time.
Prefer frontmatter
date/updated/orderfor deterministic sort. navigation.jsonlives at the source root β for GitHub that'sVERTO_GITHUB_PATH/navigation.json, for OneDrive it'sVERTO_ONEDRIVE_PATH/navigation.json.
Verto can show an Ask AI panel in the right rail that answers questions about the document you're currently reading, powered by GitHub Models β an OpenAI-compatible inference endpoint authenticated with a GitHub token (the same kind of token the desktop app already obtains when you Sign in with GitHub). The model sees the open document's title and text, so answers are grounded in what you're reading.
The feature is off by default. Enable it by selecting a backend:
NEXT_PUBLIC_VERTO_ASSISTANT=github # aliases: copilot, github-models
NEXT_PUBLIC_VERTO_ASSISTANT_MODEL=openai/gpt-4o-mini # optional overrideTokens are never written to the repository:
| Build | Where the token comes from |
|---|---|
| Desktop (Tauri) | Reuses the GitHub OAuth token from the device-flow sign-in. Requests go through the Tauri HTTP plugin to bypass the webview's CORS restrictions. |
| Web | You paste a GitHub token with Models access; it is kept only in your browser's localStorage and sent only to the inference endpoint. |
If the assistant is enabled but no token is available, the panel shows a short prompt (sign in on desktop, or paste a key on the web) instead of a chat box.
The assistant is built on a small pluggable AssistantProvider interface in
lib/ai/, mirroring the ContentSource design. Add a new backend by
implementing chat() in lib/ai/<name>.ts and registering it in
lib/ai/index.ts.
This project is licensed under the Apache License 2.0. See the LICENSE file for details.
The same codebase can ship as a native desktop app on macOS, Windows and Linux via Tauri 2. The web build is unchanged β desktop is opt-in.
src-tauri/holds the Rust shell andtauri.conf.json.- For desktop builds the Next.js app is statically exported
(
output: 'export', gated onTAURI=1), and Tauri loads theout/folder directly from disk β no Node server at runtime. - A small Check for updates button appears in the navbar only
when running inside Tauri (detected via
window.__TAURI_INTERNALS__), so the browser build is unaffected.
npm install # one time
npm run tauri:dev # spawns `next dev` and opens the Tauri windowThe desktop app can sign in with a GitHub account and connect to a
repository interactively at runtime β no VERTO_GITHUB_TOKEN in the
environment. It uses GitHub's OAuth Device Flow, which only needs a
public client id (there is no client secret to ship).
One-time setup (maintainer):
-
Register a GitHub OAuth App (Settings β Developer settings β OAuth Apps β New) and enable Device Flow.
-
Grant the scopes the reader needs:
repo(read private repos; usepublic_repofor public-only) andread:user. -
Expose the app's Client ID to the build. For local source builds, put it in
.env.local:NEXT_PUBLIC_VERTO_GITHUB_CLIENT_ID=Iv1.xxxxxxxxxxxx
For the released installers built by GitHub Actions (
release.yml/nightly.yml), set the Client ID as a repository Actions Variable namedVERTO_GITHUB_CLIENT_ID(Settings β Secrets and variables β Actions β Variables). The workflows inject it asNEXT_PUBLIC_VERTO_GITHUB_CLIENT_IDat build time so the shipped binaries can sign in out of the box. It is a public value, so it belongs in a Variable, not a Secret. If the variable is unset, the build still succeeds but the Sign in button reports a missing client id.
How it works at runtime:
- A Sign in button appears in the top bar only inside the desktop shell (same Tauri detection as Check for updates; the browser build is unaffected).
- Signing in opens GitHub's device-verification page in your system browser and shows a short user code to enter.
- On success the OAuth token and your profile are written to a file in
the OS app-data directory
(e.g.
~/Library/Application Support/com.tsaiggo.verto/auth.jsonon macOS,%APPDATA%\com.tsaiggo.verto\auth.jsonon Windows), with owner-only permissions (0600) on Unix. The token is never stored in the repository. - Open Integrations β Connect source, pick a repository and branch from your account, set the content path, and Save & connect. Verto verifies the path against the live repo and saves the selection alongside the token. Sign out deletes the auth file.
The cross-origin calls to github.com / api.github.com go through the
Tauri HTTP plugin (scoped to those hosts in
src-tauri/capabilities/default.json) so they bypass the webview's CORS
restrictions. The web/CI build continues to use the build-time
VERTO_GITHUB_* environment variables described under
Content Sources.
npm run tauri:build # β src-tauri/target/release/bundle/...Before the first build you need icons; generate them once from the
included icon.png at the repo root (any square β₯ 1024Γ1024 PNG works):
npx @tauri-apps/cli icon icon.pngInstallers are hosted on GitHub Releases and the in-app updater fetches its manifest from a release asset URL.
During development the updater points at the rolling nightly
prerelease so that pushes to main are immediately testable:
https://github.com/tsaiggo/verto/releases/download/nightly/latest.json
Once you cut a stable, published (non-prerelease) v* release, switch
plugins.updater.endpoints in src-tauri/tauri.conf.json to the
latest channel β GitHub's /releases/latest/ path only ever resolves
to a published, non-prerelease release:
https://github.com/tsaiggo/verto/releases/latest/download/latest.json
.github/workflows/release.yml runs on every pushed v* tag, builds
on a macOS / Windows / Linux matrix using
tauri-apps/tauri-action,
signs the artifacts, uploads them to a draft Release, and
auto-generates latest.json. Cut a release with:
git tag v0.2.0
git push origin v0.2.0
# then review and publish the draft release on GitHubThe updater verifies every downloaded package against an embedded public key. Generate the key pair once:
npx @tauri-apps/cli signer generate -w ~/.tauri/verto.keyThen:
| Where | What |
|---|---|
src-tauri/tauri.conf.json β plugins.updater.pubkey |
The public key printed by the command |
GitHub repo secret TAURI_SIGNING_PRIVATE_KEY |
Contents of ~/.tauri/verto.key |
GitHub repo secret TAURI_SIGNING_PRIVATE_KEY_PASSWORD |
The password you chose |
Back up the private key somewhere safe β if it's lost you cannot ship updates that existing installs will accept.
Made with β€οΈ by tsaiggo