Skip to content

tsaiggo/verto

Repository files navigation

🔄 Verto

The MDX reader.
Point it at a folder. Get a site. Vertō — to turn the page.

Next.js 16 React 19 Tailwind v4 TypeScript 5 License


🎯 What is Verto?

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.

Why MDX-first?

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; .md is 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.

The Obsidian analogy

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

✨ Features

MDX, rendered properly

  • 🧩 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
  • 📄 .md works too — same pipeline, same components, same output

Your folder, navigable

  • 📁 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.md if present)
  • 🎛 Surgical overrides — optional content/navigation.json to rename, sort, or hide entries without renaming files

Reading experience

  • 📊 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

🚀 Quick Start

Prerequisites

  • 📦 Node.js 18.17 or higher

Run Locally

git clone https://github.com/tsaiggo/verto.git
cd verto
npm install
npm run dev

Site runs at http://localhost:3000.

Available Commands

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

Deployment

npx vercel

Static generation by default. No config needed.


📁 Project Structure

verto/
├── app/
│   ├── page.tsx               → Reader home (sections + recently updated)
│   ├── read/[[...path]]/      → Unified document route
│   └── 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
└── 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)
    ├── 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

📝 Content Guide

Adding a Document

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

Frontmatter (all fields optional)

---
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

Directory Indexes

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.

Optional Overrides — content/navigation.json

Use this file only when you want to override what the file system would do naturally:

{
  "overrides": {
    "docs": { "title": "Docs", "order": 1 },
    "drafts": { "hidden": true },
    "notes/old-name": { "title": "New Name" }
  }
}

Keys are slug paths relative to content/, without the file extension.


🧩 MDX Block Components

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
InlineCode Styled inline code spans
UnknownComponent Placeholder shown when a doc references an unmapped JSX component

💬 Inline Comments

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

🔁 Migrating from the old Verto

The previous routes — /docs/* and /blog/* — are now permanent (308) redirects to /read/docs/* and /read/blog/*. Existing content under content/docs/ and content/blog/ continues to work unchanged.


🗄 Content Sources

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 the repo; static site, no network none
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.

GitHub

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 repos

A 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.

OneDrive

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 item

Any 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=content

Tokens are refreshed automatically each build. The implementation respects Graph @odata.nextLink pagination and backs off on 429 / Retry-After.

Caveats

  • Remote sources don't reliably surface a per-file modification time. Prefer frontmatter date / updated / order for deterministic sort.
  • navigation.json lives at the source root — for GitHub that's VERTO_GITHUB_PATH/navigation.json, for OneDrive it's VERTO_ONEDRIVE_PATH/navigation.json.

📄 License

This project is licensed under the Apache License 2.0. See the LICENSE file for details.


🖥 Desktop app (Tauri)

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.

How it works

  • src-tauri/ holds the Rust shell and tauri.conf.json.
  • For desktop builds the Next.js app is statically exported (output: 'export', gated on TAURI=1), and Tauri loads the out/ 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.

Develop

npm install            # one time
npm run tauri:dev      # spawns `next dev` and opens the Tauri window

Build a local installer

npm run tauri:build    # → src-tauri/target/release/bundle/...

Before the first build you need icons; generate them once from any square PNG / SVG (the included app icon works):

npx @tauri-apps/cli icon app/icon.svg

Releases & auto-update

Installers are hosted on GitHub Releases and the in-app updater fetches its manifest from a stable URL:

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 GitHub

One-time signing setup

The updater verifies every downloaded package against an embedded public key. Generate the key pair once:

npx @tauri-apps/cli signer generate -w ~/.tauri/verto.key

Then:

Where What
src-tauri/tauri.conf.jsonplugins.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

About

Resources

License

Stars

Watchers

Forks

Packages

 
 
 

Contributors