Porting Go's Glow to TypeScript: A Terminal Markdown Reader, Zero Dependencies
Updated 4 October 2026: a directory argument now opens the TUI (as in Glow v3), line numbers come from -l or the config rather than a # key, and Glow is not dependency-free since Glamour 2.0.0 added highlight.js and node-emoji.
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, and nothing else of its own (Glamour, since 2.0.0, brings in highlight.js and node-emoji). 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 URL, or pipe stdin — Glow renders it with Glamour and prints to stdout. (A directory opens the TUI there, as in Glow v3.)
glow README.md # local file
glow ./docs # opens the TUI in that 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 (-l / --line-numbers, or showLineNumbers in the config), 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, 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. The port looks for glow.yml in $GLOW_CONFIG_HOME if set, otherwise $XDG_CONFIG_HOME/glow, otherwise ~/Library/Preferences/glow on macOS and ~/.config/glow elsewhere. 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 TypeScript package with no native code. 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 depending only on each other, apart from Glamour 2.0, which uses highlight.js and node-emoji. 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. To start using it, see Getting Started with Glow.
FAQ
Can I install Glow with npm?
Yes. npm install -g @oakoliver/glow puts a glow command on your PATH. It is a TypeScript port of Charm's Glow, so the commands and flags match the Go version.
How do I view a markdown file in the terminal with Glow?
glow README.md renders a file to stdout, cat notes.md | glow - renders stdin, and glow ./docs opens the TUI in that folder, where / filters files and Enter opens one in the pager. Glow also renders URLs and github://owner/repo, which fetches the repository's README.
Where is Glow's config file?
glow config opens glow.yml in $EDITOR. It lives in $GLOW_CONFIG_HOME if set, otherwise $XDG_CONFIG_HOME/glow, otherwise ~/Library/Preferences/glow on macOS and ~/.config/glow elsewhere. Settings such as style, width and showLineNumbers go there.
The TypeScript Charm Series
Each package is a TypeScript port of one of Charm's Go tools, published on npm and running on Node.js and Bun.
| Package | What it does | Start here | How it was ported | Source |
|---|---|---|---|---|
| @oakoliver/lipgloss | Terminal styling | Getting started | Porting story | GitHub |
| @oakoliver/glamour | Markdown rendering | Getting started | Porting story | GitHub |
| @oakoliver/bubbletea | TUI framework | Getting started | Porting story | GitHub |
| @oakoliver/bubbles | TUI components | Getting started | Porting story | GitHub |
| @oakoliver/huh | Terminal forms | Getting started | Porting story | GitHub |
| @oakoliver/glow | Markdown reader | Getting started | this article | GitHub |
| @oakoliver/gum | Shell script prompts | Getting started | — | GitHub |
| @oakoliver/vhs | Terminal GIFs | Getting started | — | GitHub |
– Antonio