Markdown Nav
Navigate a long markdown document by its headers and its tags, from the Command Palette
Details
Installs
- Total 91
- Win 46
- Mac 31
- Linux 14
| Oct 9 | Oct 8 | Oct 7 | Oct 6 | Oct 5 | Oct 4 | Oct 3 | Oct 2 | Oct 1 | Sep 30 | Sep 29 | Sep 28 | Sep 27 | Sep 26 | Sep 25 | Sep 24 | Sep 23 | Sep 22 | Sep 21 | Sep 20 | Sep 19 | Sep 18 | Sep 17 | Sep 16 | Sep 15 | Sep 14 | Sep 13 | Sep 12 | Sep 11 | Sep 10 | Sep 9 | Sep 8 | Sep 7 | Sep 6 | Sep 5 | Sep 4 | Sep 3 | Sep 2 | Sep 1 | Aug 31 | Aug 30 | Aug 29 | Aug 28 | Aug 27 | Aug 26 | |
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| Windows | 0 | 2 | 2 | 4 | 0 | 0 | 1 | 1 | 2 | 1 | 3 | 4 | 2 | 4 | 2 | 2 | 3 | 4 | 3 | 0 | 2 | 3 | 1 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 |
| Mac | 2 | 1 | 0 | 4 | 0 | 0 | 5 | 0 | 3 | 1 | 2 | 1 | 0 | 1 | 0 | 3 | 2 | 1 | 2 | 1 | 0 | 2 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 |
| Linux | 0 | 1 | 0 | 0 | 0 | 0 | 0 | 0 | 1 | 1 | 2 | 0 | 1 | 1 | 0 | 1 | 1 | 2 | 0 | 1 | 1 | 0 | 1 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 |
Readme
- Source
- raw.githubusercontent.com
Markdown Nav
Navigate a long markdown document by its headers and its tags, from the Command Palette.
Built for big notes files — the kind that accumulate for years and get too long to scroll. Two commands:
| Command Palette | What it does |
|---|---|
Markdown Nav: Jump to Header |
Walk the header tree one level at a time to the section you want |
Markdown Nav: Find Tag |
Pick a tag, then pick one of its matches, then jump there |
No key bindings are set by default. Copy what you want out of Example.sublime-keymap, via
Preferences → Package Settings → Markdown Nav → Key Bindings.
Jump to Header
Rather than one flat list of every header in the file, this descends the outline a level at a time:
- The first panel shows the top level of the document. If the file has more than one
# H1, that's the H1s. If it has a single# H1(the usual document title), it starts a level in, at that H1's own sub-headers — so you don't click through a list of one. - Picking a header that has sub-headers opens the next level down, with
(jump here)as the first entry (which jumps to the header you just picked), followed by its children. - Picking a leaf header — one with nothing nested under it — jumps immediately, no extra panel.
Each row shows the header's depth (##), its text, its line number, and how many sub-headers it
has. Levels may be skipped: an ## H2 whose children are #### H4s works fine — each panel shows
the shallowest level actually present in that section. Headers inside fenced code blocks are
ignored. esc at any level cancels without moving the caret.
The panels reopen on your last pick. The whole chain you drilled down is remembered — ## A →
### A1 → #### detail — so each panel highlights the step you took through it and re-running the
command walks you straight back, enter enter enter. Sublime's quick panel can't be pre-filled
with filter text, so this is done with a pre-highlighted row: type to filter as usual, or press
enter immediately to re-jump. Picking (jump here) is remembered too, and highlights that row.
The memory is keyed on header text rather than position, so edits above a pick don't lose it. If a header in the chain gets renamed, the panel falls back to highlighting the branch that still contains the deepest remembered header instead of giving up. If the chain is gone entirely, no row is highlighted.
It persists across closing the file and restarting Sublime, and rides along with whatever syncs
your Packages/User between machines — see persist_last_header.
The major header level
The first panel keys off the major level: the shallowest header level that appears more than
once. A file with several # H1s has H1 as its major level; a file with a single # H1 document
title falls through to its ## H2s, since a lone title isn't a section divider.
Find Tag
Two panels: pick a tag (listed most-recently-used first, with a match count), then pick a match (with its line number), then jump. Both are fuzzy-filterable like any Sublime quick panel.
Match labels are the tagged text — what the line says with the tag itself stripped out — so the list reads as content rather than as a column of repeated tag names.
Both panels reopen on your last pick, the same way Jump to Header's do and under the same
persist_last_header setting: the tag panel highlights the tag you last used, and its
match panel the match you took. Pick a different tag and no match is pre-highlighted, since a label
from another tag's list wouldn't mean anything there.
How tags are defined
A tag has two orthogonal aspects: the form it's written in, and the scope it refers to. A tag's identity is its lowercased name.
Forms
Keyword-prefix form — heather: …
A line whose first text content (optionally after a markdown header prefix #{1,6}) is
<name>: followed by at least one space and then non-whitespace. The keyword is the tag.
The required space after the colon is what disqualifies URLs and similar:
| Matches | Does not match | Why not |
|---|---|---|
heather: lunch on Saturday |
mobilenotes://abc123 |
no space after : |
**heather:** lunch |
http://example.com |
no space after : |
**heather**: lunch |
mailto:foo@bar.com |
no space after : |
issue: bad bearings |
key:value |
no space after : |
## person: Heather |
Heather: lunch |
uppercase — see below |
Hashtag form — #heather
A #name token anywhere on the line. It must be at the start of the line or preceded by a
non-word, non-& character, so word#tag and the HTML entity — don't match.
| Matches | Does not match | Why not |
|---|---|---|
#heather |
#Heather |
uppercase — see below |
**#heather** |
#123 |
numeric |
## topic #heather |
— |
HTML entity |
we met #heather today |
word#tag |
no boundary before # |
Case. Tags are lowercase by default, in both forms. This is deliberate: it keeps tag identity
unambiguous and stops every capitalized word before a colon (Note: …, TODO: …) and every
sentence starting with a capital from becoming a tag. Change it with tag_name_pattern below if
you'd rather have uppercase count.
Bold. Tags in either form may be wrapped in **…**; the markers are stripped before the tag is
identified, and they don't affect scope.
Scopes
A tag's scope is section if either the line is a markdown header, or the line consists only of hashtag tokens. Otherwise it's single-line.
- Single-line scope — the match is that one line. Jumps to the start of the line.
- Section scope — the match is the enclosing section, anchored at the tag's own line if it's a header, otherwise at the nearest preceding header at any level, otherwise the top of the document. Jumps to the start of the anchor line.
| Example line | Form | Header? | Tags-only? | Scope |
|---|---|---|---|---|
heather: lunch on Saturday |
keyword | no | no | single-line |
## heather: lunch |
keyword | yes | — | section |
do #heather today |
hashtag | no | no | single-line |
#heather |
hashtag | no | yes | section |
#heather #family |
hashtag ×2 | no | yes | section |
## title #heather |
hashtag | yes | — | section |
Ignored
- Anything inside a fenced code block (
). - Numeric-only
#1,#123, and HTML entities&#nnn;. - Mid-token
word#tag. - A leading YAML
---frontmatter block. Itstags:key is metadata about the whole file, not an in-document tag, so it isn't reported as a tag namedtags. Turn this off withskip_frontmatter. An unterminated---is treated as a horizontal rule, not frontmatter.
Match text
The label shown for a match is the line with the tag stripped out:
| Raw line | Tag | Label |
|---|---|---|
heather: lunch on Saturday |
heather | lunch on Saturday |
**heather:** lunch on Saturday |
heather | lunch on Saturday |
* heather: lunch |
heather | lunch |
5. heather: lunch |
heather | lunch |
## person: Heather |
person | Heather |
we met #heather today |
heather | we met today |
* #heather alone item |
heather | alone item |
* item with #heather inline |
heather | * item with inline |
## title #heather |
heather | title |
The rules: strip the tag and its bold markers; if the tag was at the start of the line, strip the
leading whitespace, bullet marker (*, -, +, N., N)) and header marker too; if the line is
a header, strip the header marker regardless of where the tag was; if the tag was mid-line, remove
only the tag and keep the surrounding text as context; then collapse and trim whitespace.
When stripping leaves nothing, the line was tag-only — which means section scope — so the label
comes from the section anchor instead, or (top of doc) if there's no preceding header.
Settings
Preferences → Package Settings → Markdown Nav → Settings, or
Preferences: Markdown Nav Settings in the Command Palette.
{
"tag_name_pattern": "[a-z][a-z0-9_-]*",
"skip_frontmatter": true,
"persist_last_header": true
}
*tag_name_pattern** — a Python regex string for a tag name, spliced into both forms. To also
accept uppercase, use “[A-Za-z][A-Za-z0-9_-]”. To allow dots,“[a-z][a-z0-9._-]*”. It must not
contain a capture group — use(?:…) if you need grouping — because the tag name has to stay as
group 1 for scope and label extraction to work. A value that isn't a string, won't compile, or adds
a group is ignored with a note in the status bar and the default is used instead.
**skip_frontmatter** — whether to skip a leading YAML --- block when scanning for tags.
persist_last_header — whether the last pick of both commands survives closing the file and
restarting Sublime. (The name is historical; it covers Find Tag's tag and match too.)
State goes in Packages/User/Markdown Nav.state.json, keyed on file path:
{
"version": 1,
"files": {
"/Users/you/notes/journal.md": {
"at": 1788992846,
"header": [[2, "September"], [3, "2026-09-09"]],
"tag": ["win", "shipped the thing"]
}
}
}
Packages/User is where your settings live, so whatever already syncs them between machines
carries this too — a Dropbox symlink, Sync Settings, a git repo. That's the reason it isn't in
Cache/, which is machine-local by design. The tradeoff of a synced file is that two machines
writing at once can produce a conflicted copy; writes are atomic and coalesced (one per ~2s of
jumping, not one per jump), and an unreadable file is reported in the status bar and reset rather
than raising. Delete the file to forget everything.
The 200 least-recently-used files are kept, pruned on write. Set the setting to false and the
memory lasts only for the session, per view, with nothing written to disk. Unsaved buffers are
always session-only — there's no path to key them on.
Everything else about tag handling is structural rather than a preference, and lives in code: which
of the two forms a line uses, whether the scope is a line or a section, where a section's anchor is,
and how a label is stripped. Those are interdependent — the patterns carry a contract that group 1
is the tag name — so exposing them as raw regex settings would let a bad override break scope and
labels silently instead of failing loudly. tag_name_pattern is the one part that varies
independently of the rest.
Requirements
Sublime Text 4, build 4107 or newer (it runs in the Python 3.8 plugin host).
Layout
mdnav.py # both commands, the parsing helpers, the last-pick store
Markdown Nav.sublime-settings # tag_name_pattern, skip_frontmatter, persist_last_header
Default.sublime-commands # Command Palette entries
Main.sublime-menu # Preferences -> Package Settings -> Markdown Nav
Example.sublime-keymap # suggested bindings; Sublime never loads this file
tests/test_tags.py # standalone, stubs `sublime`, no ST needed
Command names are mdnav_jump_to_header and mdnav_find_tag — the mdnav_ prefix predates the
rename from “mdnav” to “Markdown Nav” and is kept so existing key bindings keep working.
Tests
py tests/test_tags.py
License
MIT — see LICENSE.