Porting Go's Glow to TypeScript: A Terminal Markdown Reader, Zero Dependencies
Lipgloss styles text. Glamour renders markdown. Bubbletea runs the event loop. Bubbles provides the components. But Glow is the application — the thing that ties all four libraries together into something a user actually runs.
In Go, Charmbracelet's Glow is a terminal markdown reader with two modes: a CLI that renders markdown to stdout, and a full interactive TUI with file browsing, fuzzy filtering, and a viewport-based pager. It depends on the entire Charm ecosystem plus ten external Go packages.
Now it's available in TypeScript. This is the fifth and final port.
I – What Shipped
@oakoliver/glow — a terminal markdown reader with CLI and interactive TUI modes. Render markdown files, URLs, GitHub repos, GitLab repos, or piped stdin directly in your terminal.
npm install -g @oakoliver/glow
glow README.md
4,254 lines of TypeScript across 22 source files. 60 tests, 78 expects. Dependencies on the four previously ported Charm packages — but zero external runtime dependencies beyond those. Every Go dependency that wasn't part of the Charm ecosystem was replaced with a pure TypeScript implementation.
II – Two Modes, One Application
CLI Mode
The simplest path. Pass a file, a directory, a URL, or pipe stdin — Glow renders it with Glamour and prints to stdout.
glow README.md # local file
glow ./docs # finds README in directory
glow https://example.com/doc.md # fetches and renders URL
glow github://user/repo # fetches README via GitHub API
cat notes.md | glow - # reads from stdin
The CLI handles argument parsing (replacing Go's cobra), automatic style detection based on terminal background color, configurable word wrap widths, optional pager piping ($PAGER), and style selection from Glamour's built-in themes.
TUI Mode
Run glow with no arguments in a directory containing markdown files. You get a full interactive application:
File browser — discovers all .md and .markdown files recursively, respecting .gitignore rules. Files are listed with relative paths and modification timestamps displayed as relative times ("2 hours ago", "3 days ago").
Fuzzy filter — press / and start typing. Characters are matched non-contiguously across filenames, with matched characters highlighted. Results re-sort by match quality in real time.
Document pager — select a file and it renders through Glamour into a scrollable viewport. Vim-style navigation (j/k, d/u, g/G), line numbers (toggle with #), live reload via fs.watch (edit the file externally and the pager updates instantly), clipboard copy (c), and editor launch (e).
III – Replacing Ten Go Dependencies
Glow's Go source imports ten packages outside the Charm ecosystem. Each required a from-scratch TypeScript replacement.
Argument Parsing (replaces spf13/cobra)
Cobra is a full CLI framework — commands, subcommands, flags, help generation. Glow only uses a fraction of it: a root command with flags. I wrote a simple argument parser in cli.ts that walks process.argv, matching --flag value and --flag=value patterns, handling boolean flags, and collecting positional arguments.
The tricky part was the - argument. My first pass had a catch-all arg.startsWith('-') clause for unknown flags, which rejected bare - — the standard Unix convention for "read stdin." One character of logic: && arg !== '-'.
Configuration (replaces spf13/viper + caarlos0/env + muesli/go-app-paths)
Viper is a 5,000-line configuration library. Glow uses it to read a YAML config file from ~/.config/glow/glow.yml and merge it with environment variables.
I wrote a minimal YAML parser — just the subset Glow needs: key-value pairs, strings, booleans, numbers. XDG path resolution handles $XDG_CONFIG_HOME with platform-appropriate fallbacks. Environment variables are read directly from process.env. The whole config system is 189 lines.
.gitignore-Aware File Walking (replaces muesli/gitcha)
The TUI's file browser needs to find all markdown files while respecting .gitignore rules. The Go code imports gitcha, which wraps Go's filepath.WalkDir with gitignore pattern matching.
My implementation in filewalk.ts (275 lines) walks the directory tree with fs.readdirSync, loading .gitignore files at each directory level, and matching paths against gitignore glob patterns. The pattern matcher handles *, **, ?, negation with !, directory-only patterns with trailing /, and anchored vs. unanchored rules.
Fuzzy Matching (replaces sahilm/fuzzy)
The stash filter needs fuzzy matching — typing "rm" should match "README.md". I implemented the scoring algorithm in stash.ts: walk pattern characters against each target, track matched indices, score based on consecutive matches and word boundary positions, sort by score descending. Matched character indices are passed to stashitem.ts for highlighted rendering.
Clipboard (replaces atotto/clipboard)
Cross-platform clipboard access via child_process.execSync: pbcopy on macOS, xclip or xsel on Linux, clip on Windows. Three lines of platform detection, one execSync call.
File System Watching (replaces fsnotify/fsnotify)
Live reload in the pager uses Node.js fs.watch(). When the watched file changes, a ContentReadyMsg is dispatched and the viewport re-renders. Simpler than Go's fsnotify, which needs separate goroutine management.
Relative Time Formatting (replaces dustin/go-humanize)
"2 hours ago", "yesterday", "3 months ago". A single function — relativeTime() — with threshold-based formatting. Under 60 seconds: "just now". Under 60 minutes: "X minutes ago". And so on through hours, days, weeks, months, years. 30 lines.
Editor Launch (replaces charmbracelet/x/editor)
Opening $EDITOR on a file: read the environment variable, fall back to vi, child_process.spawnSync with stdio: 'inherit'. The TUI suspends during editing and resumes after.
IV – Architecture Decisions
CLI Entry vs. Library Exports
Glow is primarily a CLI application, not a library. But it's useful to expose internals for programmatic use. The package has two entry points:
dist/cli.js— the bin entry with a shebang banner, handlesprocess.argvand runs the applicationdist/index.js— library exports forloadConfigFile,defaultConfig, URL utilities, file walking, markdown helpers
The build produces both via separate esbuild invocations: one with --banner:js="#!/usr/bin/env node" for the CLI, one without for the library.
A discovery during development: the source file initially had a #!/usr/bin/env node shebang AND esbuild's --banner added another. Node.js choked on the duplicate. Fix: remove the shebang from source, let esbuild handle it.
Interface Boundaries Between TUI Components
The TUI has three main models: GlowModel (root), StashModel (file browser), and PagerModel (document viewer). In Go, these are in the same package and can access each other's fields freely.
flowchart TD
GlowModel["GlowModel (root)"]
StashModel["StashModel (file browser)"]
PagerModel["PagerModel (document viewer)"]
Viewport["ViewportModel"]
TextInput["TextInputModel"]
GlowModel -- "resize() → setSize()" --> StashModel
GlowModel -- "resize() → setSize()" --> PagerModel
StashModel -- "isFiltering getter" --> GlowModel
StashModel --> TextInput
PagerModel --> ViewportIn TypeScript, I initially defined strict interfaces for each model in ui.ts. This broke immediately — the sub-components had slightly different method signatures than what the interfaces declared. StashModel had setSize() but ui.ts called resize(). The pager's viewport was a ViewportModel instance with methods, but the interface declared it as { width: number; height: number }.
The fix was pragmatic: simplify the interfaces to only include properties that ui.ts actually accesses, and add adapter methods where names differed. resize() calls setSize() internally. A get isFiltering getter wraps the internal state check.
V – The Dependency Graph
This is the moment the whole ecosystem comes together. Glow's dependency tree:
flowchart TD
Glow["@oakoliver/glow"]
Glamour["@oakoliver/glamour"]
Bubbletea["@oakoliver/bubbletea"]
Bubbles["@oakoliver/bubbles"]
Lipgloss["@oakoliver/lipgloss"]
Glow --> Glamour
Glow --> Bubbletea
Glow --> Bubbles
Glow --> Lipgloss
Glamour --> Lipgloss
Bubbles --> Bubbletea
Bubbles --> Lipgloss@oakoliver/glow
├── @oakoliver/glamour (markdown → styled terminal output)
├── @oakoliver/lipgloss (style primitives, color, layout)
├── @oakoliver/bubbletea (Elm Architecture event loop)
└── @oakoliver/bubbles (viewport, text input, spinner, paginator, help)
Glamour renders the markdown content. Lipgloss styles every UI element — the stash list items, the status bar, the help text, the filter prompt. Bubbletea drives the event loop. Bubbles provides the viewport for scrolling, the text input for the filter, the paginator for page navigation, and the spinner for loading states.
Every one of these is a zero-dependency TypeScript package. The entire stack, from raw ANSI escape sequences up through a full interactive markdown reader, is pure TypeScript. No native modules, no C bindings, no wasm, no bundled binaries.
VI – Test Coverage
The Go source for Glow has minimal tests — it's an application, not a library. I wrote comprehensive tests for every internal module:
| Module | Tests | Expects |
|---|---|---|
| CLI flag parsing | 11 | 11 |
| URL handling | 8 | 8 |
| Frontmatter removal | 6 | 6 |
| Utility functions | 7 | 7 |
| Markdown helpers | 13 | 13 |
| Config loading | 7 | 7 |
| UI style helpers | 8 | 8 |
| Stash item rendering | 5 | 5 |
| Stash help | 2 | 2 |
| Fuzzy filtering | 3 | 3 |
| Gitignore patterns | 8 | 8 |
| Total | 60 + 8 skipped | 78 |
The 8 skipped tests are network-dependent (GitHub API, GitLab API, URL fetching) — they pass when run with network access but are skipped in CI to avoid flakiness.
VII – The Complete Ecosystem
Five packages. All published. All zero-dependency (or depending only on each other). All ported from Go with test parity.
| Package | npm | Lines | Tests |
|---|---|---|---|
| @oakoliver/lipgloss | 1.0.0 | ~4,800 | 328 tests, 595 expects |
| @oakoliver/glamour | 1.0.0 | ~3,200 | 317 tests, 688 expects |
| @oakoliver/bubbletea | 1.0.0 | ~2,100 | 46 tests, 73 expects |
| @oakoliver/bubbles | 1.0.0 | ~11,700 | 256 tests, 589 expects |
| @oakoliver/glow | 1.0.0 | ~4,250 | 60 tests, 78 expects |
| Total | ~26,050 | 1,007 tests, 2,023 expects |
Over twenty-six thousand lines of TypeScript. Over a thousand tests. The entire Charmbracelet terminal UI ecosystem — from style primitives through markdown rendering through interactive components through a full application — available to anyone writing TypeScript.
npm install @oakoliver/lipgloss # style text
npm install @oakoliver/glamour # render markdown
npm install @oakoliver/bubbletea # run TUI apps
npm install @oakoliver/bubbles # use pre-built components
npm install -g @oakoliver/glow # read markdown in your terminal
The ecosystem is complete.
– Antonio