docv
docv
Parses manual pages and Markdown documents and shows them in a terminal viewer. Written in Crystal; besides the standard library it uses markd for Markdown and treesitter for syntax highlighting.
- Parser. A roff interpreter with the man(7) and mdoc(7) macro packages and the tbl(1) preprocessor built in natively. It reads plain and gzip page files and follows
.soincludes. Markdown files (CommonMark with the GitHub extensions) go through markd into the same model (see Markdown). The result is a document model of blocks and styled spans, with link targets and heading anchors. - Layout. Turns a document into lines of cells. The wrapped layout fills paragraphs to the width. The unwrapped layout puts each paragraph on one line. Unfilled text and tables never wrap. Lines carry their links: the span links of a document, and cross-references such as
ls(1)detected in the text. - Viewer. Uses a cell-grid renderer that diffs the screen row by row. Keyboard input uses the kitty protocol, with all enhancements pushed and legacy encodings understood. Mouse input uses SGR-pixel reports. The viewer also handles in-band resize, focus, visibility and color-scheme notifications. The screen is a title row (
DOCVand the version, the always-visible search bar, and a 🞪 close mark), a line, the page with a draggable vertical scrollbar in the last column (only its thumb is drawn), a horizontal scrollbar line (thumb━, track─) and a status row. The page header of a manual page (NAME(1) manual NAME(1)) stays pinned at the top above a rule, and the page footer (source date NAME(1)) at the bottom below one, while the body scrolls between them, vertically and horizontally. The search bar fuzzy-finds in the displayed page, highlighting matches in place, or, after a leading/, lists all pages of the manual and the Markdown documents of the project, the project's first. It also has links with back navigation (cross-references, files, heading anchors; URLs as OSC 8 hyperlinks), mouse text selection copied to the clipboard, and a floating help (h) listing the keys as they are bound. Everything is colored by role from the terminal's own theme, which it queries with OSC 21 (see Colors).
Requirements
- Crystal 1.21 or newer
- Linux (the viewer uses
termiosandTIOCGWINSZ) - markd 0.6 and treesitter (installed by
shards install) - libtree-sitter 0.25 or newer, found through
pkg-config(Fedora:libtree-sitter-devel); it is linked in always - for syntax highlighting: tree-sitter grammars and their
highlights.scmqueries (see Syntax highlighting)
Installation
dependencies:
docv:
github: shpeckman/docv
The command-line viewer:
shards install
make release # bin/docv
make install # ~/.local/bin/docv, man pages in ~/.local/share/man
The manual pages are man/man1/docv.1 (the command, its keys, mouse, search path, environment and files) and man/man5/docv-config.5 (config.ini). Inside this repository docv docv finds them as project pages; after make install, man docv and man docv-config work too.
Command line
docv [-u]
docv [-u] [section] name
docv [-u] name(section)
docv [-u] file
docv path-add dir...
docv path-rm dir...
docv path-list
docv gen-cr [-o dir] [shard]
A file is a manual page, or a Markdown document when its name ends in .md, .markdown, .mdown, .mkd or .mkdn (optionally followed by .gz): docv README.md. Without a page, the viewer opens on an empty page with the page search focused (this needs a terminal). The viewer starts in the wrapped layout unless [viewer] start-wrapped = false is set in config.ini; -u (--unwrapped) starts in the unwrapped layout either way. When standard output is not a terminal, the page is printed as plain text at MANWIDTH columns (default 80).
Search path
Pages are looked up, and listed by the page search, along this path, earlier entries first:
- The project around the current directory. The project root is the nearest directory upward that holds a marker:
.git,.hg,.svn,.jj,shard.yml,Cargo.toml,package.json,go.mod,pyproject.toml,setup.py,Gemfile,pom.xml,build.gradle,CMakeLists.txt,meson.build,configure.ac, or aMakefile. The search stops below the home directory. Without a marker, the current directory itself is the root, unless it is the home directory or/. The root and its subfolders are searched up to 4 levels deep, skipping hidden folders,node_modules,vendor,lib,target,__pycache__and symbolic links. Two kinds of directory count: trees with section directories (man/man1/tool.1), and directories with pages in them (doc/tool.1, or the root itself). A page there is a file namedname.section(optionally.gz) whose text starts with a roff control character. The Markdown documents found along the way (not hidden ones) are listed by the page search, by their path from the project root. - The added directories, from
docv path-add. - The system path, built as man-db does (manpath(5)):
MANPATH, where each empty component stands for the default path;- otherwise the default path: for each
PATHelement, itsMANPATH_MAPdirectories from/etc/man_db.conf(or/etc/manpath.config), or else whichever of../man,man,../share/manandshare/manexist next to it; - then the
MANDATORY_MANPATHdirectories.
Locale subdirectories of a tree come before it (LC_ALL, LC_MESSAGES, LANG), and sections are searched in man-db's order.
| Command | Does |
|---|---|
docv path-add dir… |
adds directories (made absolute) to $XDG_CONFIG_HOME/docv/paths; each must exist; a directory without man pages gets a warning |
docv path-rm dir… |
removes them; fails for a directory that was not added |
docv path-list |
prints the search path, each entry with its source (project, added, system); (pages) marks a directory with pages in it |
paths holds one directory per line, and lines starting with # are comments. path-add and path-rm rewrite the file, so comments you add by hand are not kept.
Man pages of a Crystal shard
docv gen-cr generates man pages for a Crystal shard: the nearest directory from shard (default: the current directory) upward that holds a shard.yml. It runs crystal docs --format json there and renders, into dir (default man, relative to the shard root):
| Page | From |
|---|---|
man7/name.7cr |
README.md: NAME from its first sentence, its sections, then the list of types |
man3/Type.3cr |
each documented type: synopsis with its require, description, inheritance, related types, constants or enum members, constructors, class and instance methods, macros, source files, see also |
man3/name-toplevel.3cr |
the top level methods, macros and constants, when there are any |
The cr suffix keeps the pages from shadowing C library pages. Being under man/ in the project, they show up right away in docv's page search (/Bar) and as docv Foo::Bar. Doc comments are Markdown (read with markd): code spans and bold, italic, lists, code blocks, quotes and tables become roff, external links get their URL in angle brackets, and paragraphs starting with NOTE:, TODO:, DEPRECATED: and the other Crystal flags get a bold label. Generated members (record's clone and copy_with, serializer constructors, enum predicates) are left out.
A page whose content is unchanged keeps its file and date, and generated pages that are no longer produced are removed. Every page carries the comment Generated by docv gen-cr from README.md and the doc comments; regenerate instead of editing.; a file in the way without it stops the run. SOURCE_DATE_EPOCH sets the date for reproducible builds.
Configuration
The configuration file is $XDG_CONFIG_HOME/docv/config.ini. When XDG_CONFIG_HOME is unset or not an absolute path, ~/.config/docv/config.ini is used. The first run writes a commented default file there. Unknown sections or keys and invalid values are reported as errors.
[roff]
; run sy, pso, pi, open, opena, write, writec, writem, close, cf, trf, rd and \V
unsafe = false
[viewer]
start-wrapped = true
syntax-highlighting = true
| Key | Default | Meaning |
|---|---|---|
roff.unsafe |
false |
run the requests that execute commands or access files (see roff coverage); booleans accept true/false, yes/no, on/off, 1/0 |
viewer.start-wrapped |
true |
start in the wrapped layout; false starts unwrapped (w switches, -u starts unwrapped either way) |
viewer.syntax-highlighting |
true |
color code examples by their syntax (see Syntax highlighting) |
Colors
The viewer draws every element in a role, and [colors] in config.ini restyles roles. The default file lists every role with its default, commented out.
[colors]
heading = bold #ff8800
field = on background+8
selection = black on bright-cyan
A style is attribute words (bold, italic, underline, reverse, dim), a foreground color, and on followed by a background color. A color is one of:
default,foregroundorbackground;- an ANSI name:
black,red,green,yellow,blue,magenta,cyan,white, orbright-followed by one of them; - a palette number
0-255; #rrggbb.
Named and numbered colors are the terminal theme's own, so the defaults fit any theme. A +N or -N suffix ramps a color N percent toward the theme's foreground or background. For example, on background+12 is a bar a shade off the background in a dark and a light theme alike, and foreground-40 is text dimmed toward the background. Comments go on their own lines.
On entering, the viewer asks the terminal for its theme with OSC 21 (kitty's color control protocol: foreground, background and the 16 ANSI colors), and asks again when the color scheme changes. Once the terminal answers, ramped colors are exact and written as 24-bit color. Without an answer, xterm's colors on a black or white background (by the reported color scheme) stand in. The color depth then comes from the environment: 24-bit with COLORTERM=truecolor or 24bit or a *-direct TERM, 256 colors with a 256color TERM, the 16 ANSI colors otherwise. Colors past the depth are mapped to the nearest color the terminal shows. With NO_COLOR set (and not empty), the viewer shows no colors at all and uses attributes only: bold headings, underlined arguments and links, reverse video for selections. Colors a page sets itself with roff color requests are kept, and are dropped with NO_COLOR.
| Key | Default | Styles |
|---|---|---|
colors.text |
(plain) | plain page text |
colors.heading1 |
bold bright-blue |
section headings (NAME, SYNOPSIS), level 1 document headings |
colors.heading2 |
bold blue |
subsection headings, level 2 document headings |
colors.heading3 |
bold bright-magenta |
level 3 headings |
colors.heading4 |
bold magenta |
level 4 headings |
colors.heading5 |
bold |
level 5 headings |
colors.heading6 |
bold foreground-40 |
level 6 headings |
colors.bold |
bold cyan |
bold page text: commands, options |
colors.italic |
underline green |
italic page text: arguments |
colors.bold-italic |
bold underline green |
bold italic page text |
colors.underline |
underline |
underlined page text (.ul) |
colors.code |
yellow on background+8 |
inline code in documents |
colors.code-block |
yellow |
code blocks in documents |
colors.quote |
foreground-60 |
the │ bars of block quotes |
colors.image |
italic foreground-40 |
the description standing for an image |
colors.syntax-keyword |
bold magenta |
keywords in highlighted code (keyword, conditional, repeat, include, exception, …) |
colors.syntax-string |
green |
strings and characters |
colors.syntax-comment |
italic foreground-45 |
comments |
colors.syntax-function |
blue |
functions, methods, constructors |
colors.syntax-type |
cyan |
types, namespaces, modules |
colors.syntax-constant |
red |
constants, numbers, booleans, built-in variables |
colors.syntax-operator |
foreground-15 |
operators |
colors.syntax-punctuation |
foreground-35 |
brackets, delimiters |
colors.syntax-property |
bright-cyan |
properties, fields, attributes |
colors.syntax-tag |
bright-blue |
markup tags |
colors.link |
underline magenta |
links: cross-references such as ls(1), files, headings, URLs |
colors.link-focus |
reverse magenta |
the focused link |
colors.text-selection |
on blue-50 |
text selected with the mouse |
colors.page-header |
foreground-40 |
the pinned page header and footer |
colors.title |
bold |
DOCV |
colors.version |
foreground-50 |
the version after the title |
colors.close |
red |
the 🞪 close mark |
colors.border |
foreground-60 |
the line under the title row, the horizontal scrollbar track, box edges |
colors.thumb |
bold blue |
scrollbar thumbs |
colors.rule |
foreground-70 |
the rules under the header and above the footer, horizontal rules in documents |
colors.field |
on background+12 |
the search bar; its ◖ ◗ ends take its background as their color |
colors.prompt |
bold blue |
the leading / of a page search |
colors.placeholder |
foreground-50 |
the hint in the empty search bar |
colors.cursor |
reverse |
the input cursor |
colors.message |
yellow |
messages such as Copied 29 characters |
colors.status |
foreground-30 |
status row text |
colors.status-separator |
foreground-60 |
the │ between status parts |
colors.name |
bold cyan |
page and document names in the page search |
colors.detail |
foreground-35 |
descriptions and section names in the lists |
colors.selection |
on background+22 |
the selected listed page; the line of the selected match in the page |
colors.hover |
on background+10 |
the listed page under the pointer |
colors.mark |
bold underline yellow |
fuzzy-matched characters, in the list and in the page |
colors.popup |
on background+6 |
the help box (its edges in border, its keys in name) |
Keys
| Keys | Action |
|---|---|
j ↓ Ctrl-n Ctrl-e |
line down |
k ↑ Ctrl-p Ctrl-y |
line up |
Space Ctrl-f PgDn |
page down |
b Ctrl-b Shift-Space PgUp |
page up |
d Ctrl-d / u Ctrl-u |
half page down / up |
g < Home / G > End |
top / bottom |
l |
scroll right 8 columns |
H / L |
scroll left / right half a screen |
0 / $ |
line start / line end |
w |
toggle wrapped and unwrapped layout |
f t |
focus the search bar: find in the page |
/ o |
focus the search bar with / typed: find a page or project document |
Tab / Shift-Tab |
focus next / previous link |
Enter |
follow the focused link (see Links) |
Backspace Alt-← |
back to the previous page |
h |
help: the keys as bound and the mouse gestures; j/k and the other scrolling keys scroll it, any other key or a click closes it |
Esc |
clear link focus, selection and message |
Ctrl-l |
redraw |
q Q Ctrl-c |
quit |
Bindings are looked up by physical key first (the kitty base-layout code), then by the reported key, then by the shifted symbol without its shift. That makes G and $ work on any layout.
The status row shows messages such as Copied 29 characters on the left. On the right it shows the layout, the visible lines of the body and the first column: [wrap] │ 1-56/801 │ col 1. While finding in the page it starts with the selected match and the match count; while listing pages it shows the result count instead, and the indexing progress while page descriptions load.
Search bar
The search bar sits in the title row between ◖ and ◗ and is always visible. f, t or a click on it gives it the keyboard; / and o do the same with / already typed.
- Text without a leading
/fuzzy-finds in the lines of the displayed page, in its current layout. The page stays in the frame: matched characters are highlighted where they are, the selected match's line is shaded, and the page scrolls to the best match as you type.↓/↑step through the matches, best first, and the status row showsmatch 2/8.Enterleaves the bar at the match;Escreturns to where you were. - Text after a leading
/lists the pages of the manual and the documents of the project in the frame. It lists every page along the manual path asname(section)with the description from its NAME section, and every Markdown document of the project by its path from the project root (README.md,docs/guide.md) with its first heading and paragraph. The project's pages and documents come first, then the other pages; that holds while searching too, so a project match ranks above a better match elsewhere. A term matches the reference or the description and counts with the better of the two; a reference match counts half a character more than an equal description match. So/printf 3findsprintf(3), and/list dirfindsdir(1)andls(1)by their descriptions. Ties go to the shorter reference, then to section order.Enteropens the page or document, andBackspacein the page view comes back. Without a page on the command line the viewer starts here.
Both are fuzzy: the characters of each space-separated term must appear in order, and matches score higher at word starts, in runs of consecutive characters and with short gaps (scored like fzf). Every term must match. Lower-case terms ignore case; a term with an upper-case letter matches exactly. Matched characters are underlined in the results.
| Keys | Action |
|---|---|
| text | type into the bar |
Enter |
stay at the match / open the selected page |
Esc Ctrl-c Ctrl-g |
leave the bar and empty it (back to where finding in the page started) |
Backspace |
delete the last character |
Ctrl-u |
clear the bar |
↓ Ctrl-n Tab / ↑ Ctrl-p Shift-Tab |
next / previous match or page |
PgDn / PgUp |
a screenful of matches or pages down / up |
Page names are listed on the spot. Descriptions come from parsing the start of each page, which takes a few seconds for a full manual. That work runs in the background while the list is in use, with progress shown in the status row; the project's documents are described first. The page descriptions are cached in $XDG_CACHE_HOME/docv/index.tsv (~/.cache/docv/index.tsv when the variable is unset or not absolute). A directory whose modification time is unchanged comes from the cache; in a changed directory only files with a new modification time are read again. Document descriptions are read on every start. Quitting early keeps the descriptions done so far.
Links
Tab focuses the links on the page in turn, the status row shows where the focused one leads, and Enter or a click follows it. Backspace comes back to the place it was followed from.
| Link | Following it |
|---|---|
name(section) in the text |
opens the page from the search path |
[text](other.md#usage) |
opens the file relative to the current one, at the heading usage |
[text](#usage) |
goes to the heading usage of the current document |
[text](https://…), mailto: … |
shows the URL; the terminal opens it (OSC 8) |
Anchors are GitHub's: the heading in lower case, punctuation dropped, spaces as dashes, -1, -2… appended to repeated headings. Links with a scheme are written to the terminal as OSC 8 hyperlinks. Most terminals open them on a click with a modifier (kitty: Ctrl-Shift-click), because the viewer takes plain clicks itself.
Mouse
| Gesture | Action |
|---|---|
| wheel | scroll 3 lines |
Shift-wheel, horizontal wheel |
scroll 8 columns |
| drag in the text | select text; copied to the clipboard on release (OSC 52) |
| double-click | select the word (letters, digits, _ and -, so --color whole) or the run of punctuation, and copy it |
| triple-click | select the paragraph (the lines with text around it in blocks of the same kind; a heading alone) and copy it |
| drag a scrollbar thumb (right and bottom border) | scroll |
| click a scrollbar track | page in that direction |
| click a link | follow it |
| click the search bar | focus it |
| hover over a listed page | highlight it |
| click a listed page | open it |
| back button (Aux8) | back to the previous page |
| click 🞪 (end of the title row) | quit |
While pages are listed, the wheel and the right border scroll the list. A selection stays highlighted until the next click or key. Selections follow the indentation: a line's indentation and trailing blanks are never highlighted, and the copied lines lose the indentation they share, so a paragraph comes out flush left and code keeps its relative indentation.
The arrow keys ← and → do not scroll sideways; l, H, L, 0, $, Shift-wheel and the horizontal scrollbar do. Code never wraps, also in the wrapped layout: code blocks (.EX, .nf, .Bd -literal, .Dl, fenced and indented Markdown code) keep their lines, and Markdown code spans are not broken across lines; the page scrolls sideways to show long ones. The clipboard write first sends an invalid payload, which clears the clipboard in terminals that append successive OSC 52 writes (kitty) and is ignored elsewhere.
Markdown
Markdown is read as CommonMark with the GitHub extensions: tables, task lists, strikethrough, bare URLs as links (www., https://, e-mail addresses) and alerts (> [!NOTE]).
| Element | Shown as |
|---|---|
| paragraph | filled to the width; a hard line break (two spaces, \) starts a new line |
heading # … ###### |
its own line in the role of its level (heading1 … heading6), with a blank line after |
**bold**, *italic* |
the bold and italic roles |
~~strikethrough~~ |
struck through (SGR 9) |
`code` |
the code role |
| code block | its lines unwrapped, indented by 4, in code-block; tabs every 8 columns |
| list | items hanging on • ◦ ▪ by depth, 1. (widest number), ☐ / ☑ for tasks; loose lists keep a blank line between items |
| block quote | a │ bar per level, in quote; an alert starts with its title (Note) in bold |
| table | boxed as wide as the terminal (spare width spread over the columns by their widths), the header row bold and ruled off, cells aligned and filled to their column |
--- |
a rule across the width, in rule |
| image | its description, in image (the file name without one) |
| raw HTML | dropped, keeping the text between the tags |
Markdown documents have no pinned header and footer.
Syntax highlighting
With syntax-highlighting = true (the default), code examples are colored by their syntax through tree-sitter:
- Markdown code blocks labeled with a language:
```crystal. The first word of the label names the grammar, or an alias of it (cr→crystal,sh/shell→bash,js→javascript,py→python,yml→yaml, …). Unlabeled and indented code blocks and code spans stay plain. - The
.EXexamples of manual pages whose section names a language:Foo.3crandname.7cr, the pagesdocv gen-crmakes, are Crystal.
A language takes two things, and docv ships neither: its grammar, a shared library libtree-sitter-<name>.so or <name>.so, and its highlight query, <name>/highlights.scm. They are looked for where tree-sitter and editors install them, for example grammars in ~/.local/lib with queries in ~/.local/share/tree-sitter/queries/<name>/highlights.scm:
Grammars (after ~/.local/lib, /usr/local/lib, /usr/local/lib64, /usr/lib, /usr/lib64) |
Queries |
|---|---|
$XDG_DATA_HOME/docv/grammars |
$XDG_DATA_HOME/docv/queries, $XDG_DATA_HOME/tree-sitter/queries |
$XDG_DATA_HOME/nvim/site/parser |
$XDG_DATA_HOME/nvim/site/queries |
$XDG_DATA_HOME/nvim/lazy/nvim-treesitter/parser |
$XDG_DATA_HOME/nvim/lazy/nvim-treesitter/queries, /usr/share/nvim/runtime/queries |
$XDG_CONFIG_HOME/helix/runtime/grammars, /usr/lib64, /usr/lib and /usr/share helix/runtime/grammars |
the same helix/runtime/queries directories |
(XDG_DATA_HOME defaults to ~/.local/share, XDG_CONFIG_HOME to ~/.config.) A grammar can be built from its repository with tree-sitter build, whose queries/highlights.scm is the query. A query's ; inherits: a,b line puts those languages' queries first. A language without a grammar or query, or whose query the grammar rejects, stays plain.
Capture names map to the syntax-* roles by their longest known dotted prefix (keyword.function is a keyword; nvim's and helix's names both work); variable and unknown names stay in the code's own color (code-block in documents, plain in manual pages). An inner capture overrides the one around it. Of two captures of the same node, the later pattern wins, as in nvim; for helix's query directories the earlier one does.
Library
require "docv"
doc = Docv.open("ls", "1") || abort "no page" # find + load (added directories and system path)
manpath = Docv::Manpath.default(Dir.current) # with the man pages of the project around the current directory
path = manpath.find("tool", "1")
doc = Docv.load("/usr/share/man/man1/ls.1.gz") # file, plain or gzip
doc = Docv.load("README.md") # Markdown by file name (Markdown.file?)
doc = Docv.parse(source, path) # roff text; path resolves .so
doc = Docv::Markdown.parse(text) # Markdown text
doc = Docv.load(path, Docv::Options.new(unsafe: true, stdin: STDIN))
doc = Docv.load(path, Docv::Config.load.options) # options from config.ini
doc = Docv.load("README.md", Docv::Options.new(highlight: true)) # with syntax highlighting
doc.format # Docv::Format::Man | Mdoc | Roff | Markdown
doc.reference # "LS(1)"; a document's first level 1 heading
doc.title, doc.section, doc.date, doc.source, doc.manual
doc.blocks # Array(Docv::Block)
doc.links # Array(Docv::Target): the targets of Span#link (from 1)
doc.anchors # heading anchor => block index ("installation" => 12)
doc.messages # .tm output and unknown requests or macros
Docv::Layout.new(doc).header_line(80) # the page header line, nil without .TH/.Dt and for Markdown
Docv::Layout.new(doc).footer_line(80) # the page footer line, nil without .TH/.Dt and for Markdown
Docv::Layout.new(doc).lines(80, header: false, footer: false) # the body only
Docv::Layout.new(doc).lines(80, wrap: true).each do |line|
puts line.text # cells as text
line.links.each { |link| p link } # Layout::Link: col, width, target (Target: kind, location, part)
end
index = Docv::Index.new(manpath) # pages along the manpath and its project's documents
index.describe unless index.complete? # parse the descriptions still missing; saves the cache
index.entries.each { |entry| puts "#{entry.reference} #{entry.description}" }
Docv::Index.description(path) # one page's NAME description, one document's heading and paragraph
project = Docv::Manpath.project(Dir.current) # root, man page directories, Markdown documents
fuzzy = Docv::Fuzzy.new("prf")
fuzzy.score("printf") # => 73 (nil when not a subsequence)
fuzzy.positions("printf") # => [0, 1, 5]
| Type | Holds |
|---|---|
Document |
metadata, blocks, link targets (links) and heading anchors (anchors) of a page or document |
Block |
kind (Text, Heading, Blank, Table, Rule), heading level (1-6), indent, first-line offset, tag/tag_indent, fill?, align, tabs, spans, lines, table, quote bars (columns) |
Span |
text, a style id (Styles[id] is a Style: attr (Attr: Bold, Italic, Underline, Reverse, Dim, Strike), fg, bg, markup; merge(over)) and a link (index into Document#links from 1, 0 for none) |
Markup |
what text is for the viewer's roles: None, Code, CodeBlock, Quote, Rule, Image, and SyntaxKeyword … SyntaxTag for highlighted code |
Target |
where a link leads: kind (TargetKind: Page, Url, File, Anchor), location (page name, URL, path), part (section, anchor), label |
Syntax |
Syntax.highlight(code, language) (lines of {text, Markup} runs, nil without grammar or query), apply(blocks, language), language(label) (ALIASES), page_language(path) (SECTION_LANGUAGES), markup(capture) (CAPTURES), grammar_directories, query_directories (Syntax::QueryDirectory: path, first_wins), clear |
Markdown |
Markdown.parse(text, highlight = false), Markdown.file?(path) (EXTENSIONS), Markdown.slug(heading), Markdown.description(text) |
Color |
Color::DEFAULT, Color.palette(index) (0-255), Color.rgb(r, g, b); default?, palette?, rgb? |
Palette |
the terminal theme as RGB: foreground, background, set(key, value) (OSC 21 values), reported?, dark?, rgb(color, foreground), ramp(rgb, percent), reduce(color, depth); Palette.parse(value) |
Theme |
role styles: Theme.new(overrides), Theme.parse(spec), styles(palette, depth) (a style id per Theme::Role), Theme::DEFAULT, Theme::MONOCHROME, Theme::KEYS; Theme::Error |
Table |
tbl rows of cells (Text, HSpan, VSpan, Rule, DoubleRule), alignments, minimum widths, vertical rules, gaps, frame |
Layout::Line |
cells : Array(Cell), text, links, block/offset (reading position) |
Cell |
grapheme, style id, width (2 for wide characters, 0 for their continuation) |
Manpath |
Manpath.new(dirs, locale, project = nil) (each dir a tree or a flat directory), find(name, section), roots (Manpath::Root: path, flat, project), project, all_sections; Manpath.default(dir = nil) (project directories, added directories, system path, with the project around dir), Manpath.system(manpath, path, config), Manpath.man_db/parse_man_db, Manpath.project_root(dir), Manpath.project(dir) (Manpath::Project: root, pages, documents), Manpath.discover(dir), Manpath.flat?/pages?/page?, Manpath.split(file, section), Manpath.read_paths/add_paths/remove_paths/paths_file, Manpath.parse_reference, Manpath.rank(section) |
Index |
Index.new(manpath = Manpath.default, cache = Index.cache_path) (nil cache disables it), entries (the project's pages, its documents, the other pages), pending, complete?, describe (optionally with a block receiving done and total after each entry; break stops early), save, Index.description(path) |
Index::Entry |
name (a document's path from the project root), section (empty for a document), path, description, reference, project?, document? |
Fuzzy |
Fuzzy.new(query), score(text), positions(text) (character indices), exact? (query has upper case) |
Source |
Source.read(path): gzip inflating and Latin-1 fallback; .so resolution |
Options |
unsafe (run Roff::Engine::UNSAFE requests and \V), stdin (input of .rd; nil reads nothing), highlight (syntax highlighting, default false; Config#options sets it from syntax-highlighting) |
Config |
Config.load(path = Config.path, create = true), Config.parse(text), unsafe?, start_wrapped?, syntax_highlighting?, colors, theme, options(stdin); Config::Error |
The man page generator is a separate require too:
require "docv/crystal_pages"
root = Docv::CrystalPages::Shard.root(Dir.current) || abort "not in a shard"
puts Docv::CrystalPages.generate(root, File.join(root, "man")) # "man: 12 written, 3 unchanged, 0 removed"
| Type | Does |
|---|---|
CrystalPages |
generate(root, output), blocks(markdown) (markd nodes), summary, base_name, clean_type, plain; GENERATOR, MARKER, GENERATED, SUFFIX; CrystalPages::Error |
CrystalPages::DocTool |
load(root): runs crystal docs --format json into a Project (repository_name, body, program) |
CrystalPages::TypeDoc |
a documented type: kind, names, superclass and relations, constants, constructors, methods, macros, nested types (generated members dropped) |
CrystalPages::Shard |
Shard.root(dir), name, version, require_path(type), relative(text) |
CrystalPages::Types |
the types with pages, []?(name), lookup(context, name), referenced(type) |
CrystalPages::Renderer |
markd blocks to roff: render(blocks), item(head, blocks), tables? |
CrystalPages::Inline |
render(node, bold:, urls:, upcase:), markdown(text, urls:) |
CrystalPages::Pages |
all: the overview and type Pages (path, name, section, body, render(date)) |
CrystalPages::Generator |
run(pages): written, unchanged, removed; Generator.today (SOURCE_DATE_EPOCH) |
The viewer and its terminal layer are a separate require:
require "docv/viewer"
config = Docv::Config.load
viewer = Docv::Viewer.new(doc, path, wrap: true, options: config.options, theme: config.theme)
viewer.bindings[{Docv::Term::Mods::None, 'x'.ord}] = Docv::Viewer::Action::Quit
viewer.field_bindings[{Docv::Term::Mods::Ctrl, 'j'.ord}] = Docv::Viewer::FieldAction::Next
viewer.run
start = Docv::Viewer.new # empty page
start.perform(Docv::Viewer::Action::OpenPage) # start in the page search
start.run
| Type | Does |
|---|---|
Viewer |
the full-screen viewer; run, perform(action), bindings, field_bindings, mode (View, Search); the document is optional (empty page); options apply to pages opened through links and the page search (.rd never reads there); theme (default Theme.new) and depth (default Term::Tty.color_depth) |
Viewer::Keymap(T) |
key table, lookup(event) with physical/key/shifted matching |
Viewer::Bindings |
Keymap(Action) for the page view, from DEFAULT_BINDINGS |
Viewer::FieldBindings |
Keymap(FieldAction) for the search bar, from FIELD_BINDINGS |
Viewer::Picker |
fuzzy-ranked list of Items (label, detail, target, role, group; lower groups first): query=, results, current, move, scroll_to, marks, draw (with the role style ids) |
Viewer::Frame |
title row with search bar and close mark, title line, content area with the scrollbars, status row: geometry, bar_start/bar_end, region(row, col) (Title, Bar, Close, Border, Content, VerticalBar, HorizontalBar, Status), Frame.thumb, draw, title, bar, rule, box |
Viewer::Help |
the floating help from the bindings (ACTION_HELP, FIELD_HELP, Help::MOUSE): lines, scroll, draw, Help.key_name(mods, key) |
Viewer::Field |
the search bar text: insert, delete_back, clear |
Screen |
front and back cell buffers with hyperlinks; write, blit, fill, restyle, link(row, col, count, uri) (OSC 8), colors(palette, depth), render(io) rewrites changed rows only, with colors reduced to the depth |
Term::Tty |
raw mode, window size in cells and pixels, enter/leave of the full-screen state, query_colors (OSC 21), copy(text) (OSC 52 clipboard), Tty.color_depth (NO_COLOR, COLORTERM, TERM) |
Term::Decoder |
bytes into raw events: KeyEvent, MouseEvent, ResizeEvent, FocusEvent, VisibilityEvent, ColorSchemeEvent, ModeReport, CellSizeReport, KeyboardFlagsReport, ColorsReport (OSC 21) |
Term::Input |
derived events: pressed-set, TextInput/KeyCommand, MousePress, Click (with multi-click count), Drag (start/move/end), Scroll (accumulated), PointerEnter/PointerMove/PointerLeave; pixel-to-cell conversion |
Terminal modes
On entering, the viewer switches to the alternate screen and pushes kitty keyboard flags 31 inside it (CSI > 31 u). It enables mouse modes 1003, 1006 and 1016 and notification modes 2048, 1004, 2033 and 2031, then queries mode 1016, the cell size, the keyboard flags, visibility and the color scheme, and the theme colors with OSC 21 (again when the color scheme changes). On leaving it undoes all of this in reverse order. Mouse coordinates are read as pixels once the terminal confirms mode 1016; otherwise they are read as SGR cell coordinates. Frames are written inside synchronized-update brackets (mode 2026). URL links are written as OSC 8 hyperlinks, ended before the end of each row.
roff coverage
Every groff request is recognized.
- Interpreted: definitions and strings (
deamdsasand their1/ivariants,rmrnalschoplengthsubstringstringupstringdown); registers (nrrrrnnalnaf); conditionals and loops (ifieelwhilebreakcontinue);returnshiftnopdoig; input (sonx);exabtmtm1tmc; traps on input lines and at the end (ititcem), plusblmlsm; diversions (didaboxboxa); environments (evevc). - Character requests:
ccc2eceoecsecrtrtrintrntcharfcharfscharscharrcharrfschar. - Fonts and colors:
ftfpftrulcugcolorfcolordefcolor. - Filling and spacing:
brbrpfinfadnacerjspbpnsrsintilllttatl. - Escapes: all of them, including
\*,\n(with auto-increment),\$,\g,\w,\B,\A,\R,\f,\m,\M,\h,\l,\(xx,\[name](groff names,uXXXX, accented letters),\C,\N,\c,\p,\Z,\oand\b.
Some requests are accepted but do nothing, because they are pointless on a reflowing terminal view: page geometry and page traps (pl po pn wh ch dt vpt mk rt ne os sv), sizes and spacing (ps vs ls ss cs bd sizes fzoom), hyphenation (hy nh hc hw hla hlm hpf* hym hys shc hcode), kerning and ligatures, line numbering and margin characters (nm nn mc), device and debugging output (device devicem output pm pnr ptr pev pline pvs backtrace), warnings, fam fc lc tc uf linetabs, and macro package loading (mso msoh, since the packages are built in).
The unsafe requests run only with roff.unsafe = true (Options#unsafe). Otherwise they are accepted and ignored:
| Request | With unsafe |
|---|---|
sy cmd |
runs /bin/sh -c cmd; the raw wait status goes to register systat |
pso cmd |
runs the command and reads its output as input |
pi cmd |
after parsing, pipes the page formatted as text at 78 columns into the command |
open s file / opena s file |
opens stream s for writing / appending |
write s text / writec s text |
writes text (copy mode, leading " removed) with / without a newline |
writem s macro |
writes the macro body uninterpreted |
close s |
closes the stream (all streams close at the end of parsing) |
cf file / trf file |
sets the file's lines unprocessed as unfilled text, dropping control characters other than tab |
rd [prompt] |
prints the prompt (or a bell) on standard error, then reads input lines from Options#stdin up to an empty line |
\V[name] |
interpolates the environment variable |
Commands run with standard input and output on the null device, so they never draw over the viewer; pso captures the output. The CLI gives .rd the terminal only for the page named on the command line, before the viewer starts.
Lines are left-aligned (ragged right) and never hyphenated. .ad b is treated as left; .ad c and .ad r work, as do .ce and .rj.
Development
| Target | Does |
|---|---|
make spec |
spec suite |
make build |
debug bin/docv |
make release |
release bin/docv |
make docs |
API documentation in docs/ |
make format |
crystal tool format |
make install |
release build into ~/.local/bin, man pages into ~/.local/share/man |
docv
- 0
- 0
- 0
- 0
- 2
- about 1 hour ago
- November 3, 2026
Tue, 03 Nov 2026 06:48:57 GMT