From Plain Text to HTML in Vim – Using Python Filters for Instant Markup
If you write a lot of HTML by hand – documentation, blog posts, static site templates – you know how tedious it is to wrap lines in <p> tags, convert bullet lists into <li> blocks with proper classes, or build <select> menus from scratch.
With the RunScriptFilter workflow introduced in the previous article, we can turn any visual selection into the output of a Python script.
This article focuses entirely on Vim text to HTML transformations: instant, composable, and custom‑tailored to your markup needs.
The Core Filter (Recap)
The foundation is a Vim function that pipes selected text to an external command and replaces the selection with the command’s output. Here’s the production‑ready version we’ll use in every example:
function! RunScriptFilter(cmd) range
let l:save_reg = getreg('"')
let l:save_regtype = getregtype('"')
normal! ""gvy
let l:text = getreg('"')
call setreg('"', l:save_reg, l:save_regtype)
let l:tmp = tempname()
call writefile(split(l:text, "\n", 1), l:tmp)
let l:out = system(a:cmd . ' < ' . shellescape(l:tmp))
call delete(l:tmp)
if v:shell_error
echohl ErrorMsg
echo "Filter error:\n" . l:out
echohl None
return
endif
let l:out = substitute(l:out, '\n\+$', '', '')
execute "normal! gv\"_c" . l:out
endfunction
Any external tool that reads stdin now becomes a Vim HTML filter.
Let’s put it to work.
1. Instant Paragraph Wrapping
Select a block of text and turn each non‑empty line into a <p> element.
The Python script is trivial:
import sys
for line in sys.stdin:
stripped = line.strip()
if stripped:
print(f"<p>{stripped}</p>")
else:
print() # preserve blank lines
Map it:
xnoremap <leader>hp :call RunScriptFilter('python3 -c "
import sys
for l in sys.stdin:
s = l.strip()
print(f\"<p>{s}</p>\" if s else \"\")
"')<CR>
Select several paragraphs, hit <leader>hp, and they’re wrapped instantly.
No more manually typing <p> and </p>.
2. Headings from Plain Lines
A slightly smarter filter can detect heading levels.
Here, lines starting with # are converted to <h1> through <h4> based on the number of #:
import sys, re
for line in sys.stdin:
line = line.rstrip('\n')
m = re.match(r'^(#{1,4})\s+(.*)', line)
if m:
level = len(m.group(1))
print(f"<h{level}>{m.group(2)}</h{level}>")
else:
print(f"<p>{line}</p>")
Map: xnoremap <leader>hh :call RunScriptFilter('python3 heading_filter.py')<CR>.
Now you can write a quick outline, select it, and get semantic HTML headings and paragraphs.
3. Markdown‑Style Lists to Styled HTML Lists
Here’s where external scripts really shine. Suppose you write a list like:
1 *foo* bar
2 *foo* baz
3 *foo* qux
You want this to become:
<ol>
<li><strong style="color:#b5bd68;">foo</strong> bar</li>
<li><strong style="color:#b5bd68;">foo</strong> baz</li>
<li><strong style="color:#b5bd68;">foo</strong> qux</li>
</ol>
A small Python script handles it:
import sys, re
lines = [l.rstrip('\n') for l in sys.stdin if l.strip()]
if not lines:
sys.exit(0)
print("<ol>")
for line in lines:
# Extract index, bold part, rest
m = re.match(r'^\d+\s+\*(.+?)\*\s*(.*)', line)
if m:
bold = m.group(1)
rest = m.group(2)
print(f' <li><strong style="color:#b5bd68;">{bold}</strong> {rest}</li>')
else:
print(f' <li>{line}</li>')
print("</ol>")
Map: xnoremap <leader>hl :call RunScriptFilter('python3 mdlist2html.py')<CR>.
The same pattern works for unordered lists: just switch to <ul> and adjust the regex.
4. Generate <select> Menus from a List
Turn this:
Volvo
Saab
Mercedes
Audi
into:
<select name="cars">
<option value="0">Volvo</option>
<option value="1">Saab</option>
<option value="2">Mercedes</option>
<option value="3">Audi</option>
</select>
Python script:
import sys
lines = [l.strip() for l in sys.stdin if l.strip()]
if not lines:
sys.exit(0)
print('<select name="items">')
for i, item in enumerate(lines):
print(f' <option value="{i}">{item}</option>')
print('</select>')
Map: xnoremap <leader>hs :call RunScriptFilter('python3 select_filter.py')<CR>.
Now any list becomes a dropdown in one keystroke.
5. CSV to HTML Table
When you have comma‑separated data, generate a full HTML table with proper escaping:
import sys, csv
from html import escape
reader = csv.reader(sys.stdin)
rows = list(reader)
if not rows:
sys.exit(0)
print('<table>')
for i, row in enumerate(rows):
tag = 'th' if i == 0 else 'td'
cells = ''.join(f'<{tag}>{escape(cell)}</{tag}>' for cell in row)
print(f' <tr>{cells}</tr>')
print('</table>')
Map: xnoremap <leader>ht :call RunScriptFilter('python3 csv2table.py')<CR>.
This alone saves minutes of repetitive tag writing when you embed tables in blog posts.
6. Custom Inline Styles Based on Content
The sky is the limit.
For instance, you can write a filter that detects numbers and wraps them in <span style="color:red;">, or that automatically creates hyperlinks from URLs.
Here’s a simple one that bolds any word that starts with a capital letter:
import sys, re
def bold_caps(text):
return re.sub(r'\b([A-Z][a-z]+)\b', r'<strong>\1</strong>', text)
for line in sys.stdin:
print(bold_caps(line.rstrip('\n')))
Map it and select any block – proper names are automatically emphasised.
7. Full‑Document Conversion with Pandoc
Sometimes you want to convert an entire Markdown buffer to HTML without leaving Vim. Using the buffer‑wide variant of our filter:
command! -nargs=1 FilterBuffer call RunBufferFilter(<q-args>)
nnoremap <leader>mdh :FilterBuffer pandoc -f markdown -t html<CR>
This turns a Markdown document into a full HTML page, perfect for previewing or pasting into a CMS.
Workflow and Safety
- Undo‑friendly: The replacement is a single change –
ubrings back the original text instantly. - Error‑safe: If the script fails (non‑zero exit), the selection remains untouched and the error is displayed.
- No escaping headaches: Using temporary files avoids shell‑injection and quoting nightmares.
- Composable: You can pipe the output of one filter directly into another by running them sequentially – or chain them in a single shell pipeline inside the filter command.
Building Your HTML Editing Toolkit
I keep a handful of Python scripts in ~/bin/html-filters/ and load the corresponding Vim maps in a file that’s sourced automatically.
The consistent interface – select, press a leader key, done – means I never have to think about the underlying mechanics.
Whether I’m preparing a blog post, editing a static page template, or documenting an API, these Vim HTML filters have eliminated hundreds of repetitive keystrokes per week.
The real power of this approach isn’t any single script – it’s the mindset that your editor can become a programmable HTML generator whenever you need it. Start with one filter that solves a real annoyance in your current project, and the rest will follow naturally.