feat: compile notifications and long-running provider feedback (#55)

* feat(compiler): add compile start/complete notifications

Problem: No user-facing feedback when compilation starts or finishes.
Long-running compilers like pandoc with `--embed-resources` leave the
user staring at nothing for 15+ seconds.

Solution: Notify "compiling..." at compile start and "compilation
complete" on success. The initial `toggle` call uses a combined
"compiling with <name>..." message to avoid stacking two notifications.

* refactor(presets): use `--katex` instead of `--embed-resources --mathml`

Problem: `--embed-resources` with `--mathml` caused pandoc to inline all
assets at compile time, adding ~15s per save for KaTeX-heavy documents.

Solution: Default to `--katex`, which inserts a CDN `<script>` tag and
defers rendering to the browser. Users can opt into `--embed-resources`
or `--mathml` via `extra_args`.

* docs(presets): rewrite math rendering section for `--katex` default

* refactor(compiler): simplify notification flow, add failure notify

Problem: `compile()` used an `opts.silent` escape hatch so `toggle()`
could suppress duplicate notifications. Compilation failures had no
user-facing notification.

Solution: Remove `opts.silent` — `compile()` unconditionally notifies
on start, success, and failure. `toggle()` no longer emits its own
message.

* feat(compiler): add per-recompile notifications for long-running providers

Problem: long-running providers like `typst watch` had no per-recompile
feedback — the process stays alive and individual success/failure was
never reported.

Solution: add a persistent `output_watcher` fs_event that fires
"compilation complete" on every output mtime bump, and track
`has_errors` on `BufState` so stderr diagnostics trigger a one-shot
"compilation failed" notification. `diagnostic.set()` now returns the
diagnostic count to support this flow.

* ci: format

* chore: remove testing files
This commit is contained in:
Barrett Ruth 2026-03-06 14:58:10 -05:00 committed by GitHub
parent aeea1bd8fa
commit 39406c559c
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 217 additions and 44 deletions

View file

@ -183,8 +183,8 @@ override individual fields by passing a table instead: >lua
`latex` latexmk -pdf → PDF (with clean support)
`pdflatex` pdflatex → PDF (single pass, no latexmk)
`tectonic` tectonic → PDF (Rust-based LaTeX engine)
`markdown` pandoc → HTML (standalone, embedded)
`github` pandoc → HTML (GitHub-styled, `-f gfm` input)
`markdown` pandoc → HTML (standalone, KaTeX math)
`github` pandoc → HTML (GitHub-styled, `-f gfm`, KaTeX math)
`asciidoctor` asciidoctor → HTML (AsciiDoc with SSE reload)
`plantuml` plantuml → SVG (UML diagrams, `.puml`)
`mermaid` mmdc → SVG (Mermaid diagrams, `.mmd`)
@ -193,34 +193,36 @@ override individual fields by passing a table instead: >lua
Math rendering (pandoc presets): ~
*preview-math*
The `markdown` and `github` presets use `--mathml` by default, which converts
TeX math to native MathML markup rendered by the browser. This is the only
math option compatible with `--embed-resources` (self-contained HTML).
The `markdown` and `github` presets use `--katex` by default, which inserts a
`<script>` tag that loads KaTeX from a CDN at view time. The browser fetches
the assets once and caches them, so math renders instantly on subsequent loads.
Requires internet on first view.
`--mathjax` and `--katex` insert `<script>` tags that load JavaScript and
fonts from a CDN at runtime. Pandoc's `--embed-resources` cannot inline these
dynamic dependencies, so math fails to render in the output.
To use KaTeX or MathJax instead, override `args` to drop `--embed-resources`
(the output will require internet access). For example, to work with
github-flavored markdown (gfm): >lua
For offline use, swap in `--mathml` via `extra_args`. MathML is rendered
natively by the browser with no external dependencies: >lua
vim.g.preview = {
github = {
args = function(ctx)
return {
'-f',
'gfm',
ctx.file,
'-s',
'--katex',
'--css',
'https://cdn.jsdelivr.net/gh/pixelbrackets/gfm-stylesheet@master/dist/gfm.css',
'-o',
ctx.output,
}
end,
},
github = { extra_args = { '--mathml' } },
}
<
Note: pandoc's math flags (`--katex`, `--mathml`, `--mathjax`) are mutually
exclusive — last flag wins. Adding `--mathml` via `extra_args` (which is
appended after `args`) overrides `--katex`.
Self-contained output with `--embed-resources`: >lua
vim.g.preview = {
github = { extra_args = { '--embed-resources' } },
}
<
This inlines all external resources into the HTML. With `--katex` this adds
~15s of compile time per save (pandoc fetches KaTeX from the CDN during
compilation). Pair with `--mathml` to avoid the penalty: >lua
vim.g.preview = {
github = { extra_args = { '--embed-resources', '--mathml' } },
}
<

View file

@ -12,6 +12,8 @@ local log = require('preview.log')
---@field viewer? table
---@field viewer_open? boolean
---@field open_watcher? uv.uv_fs_event_t
---@field output_watcher? uv.uv_fs_event_t
---@field has_errors? boolean
---@field debounce? uv.uv_timer_t
---@field bwp_autocmd? integer
---@field unload_autocmd? integer
@ -41,6 +43,17 @@ local function stop_open_watcher(bufnr)
s.open_watcher = nil
end
---@param bufnr integer
local function stop_output_watcher(bufnr)
local s = state[bufnr]
if not (s and s.output_watcher) then
return
end
s.output_watcher:stop()
s.output_watcher:close()
s.output_watcher = nil
end
---@param bufnr integer
local function close_viewer(bufnr)
local s = state[bufnr]
@ -56,16 +69,17 @@ end
---@param provider preview.ProviderConfig
---@param ctx preview.Context
---@param output string
---@return integer
local function handle_errors(bufnr, name, provider, ctx, output)
local errors_mode = provider.errors
if errors_mode == nil then
errors_mode = 'diagnostic'
end
if not (provider.error_parser and errors_mode) then
return
return 0
end
if errors_mode == 'diagnostic' then
diagnostic.set(bufnr, name, provider.error_parser, output, ctx)
return diagnostic.set(bufnr, name, provider.error_parser, output, ctx)
elseif errors_mode == 'quickfix' then
local ok, diags = pcall(provider.error_parser, output, ctx)
if ok and diags and #diags > 0 then
@ -83,8 +97,10 @@ local function handle_errors(bufnr, name, provider, ctx, output)
local win = vim.fn.win_getid()
vim.cmd.cwindow()
vim.fn.win_gotoid(win)
return #diags
end
end
return 0
end
---@param bufnr integer
@ -169,6 +185,7 @@ local function stop_watching(bufnr, s)
s.watching = false
M.stop(bufnr)
stop_open_watcher(bufnr)
stop_output_watcher(bufnr)
close_viewer(bufnr)
s.viewer_open = nil
if s.bwp_autocmd then
@ -250,7 +267,11 @@ function M.compile(bufnr, name, provider, ctx, opts)
return
end
stderr_acc[#stderr_acc + 1] = data
handle_errors(bufnr, name, provider, ctx, table.concat(stderr_acc))
local count = handle_errors(bufnr, name, provider, ctx, table.concat(stderr_acc))
if count > 0 and not s.has_errors then
s.has_errors = true
vim.notify('[preview.nvim]: compilation failed', vim.log.levels.ERROR)
end
end),
},
vim.schedule_wrap(function(result)
@ -263,6 +284,7 @@ function M.compile(bufnr, name, provider, ctx, opts)
end
if result.code ~= 0 then
log.dbg('long-running process failed for buffer %d (exit code %d)', bufnr, result.code)
vim.notify('[preview.nvim]: compilation failed', vim.log.levels.ERROR)
handle_errors(bufnr, name, provider, ctx, (result.stdout or '') .. (result.stderr or ''))
vim.api.nvim_exec_autocmds('User', {
pattern = 'PreviewCompileFailed',
@ -325,10 +347,54 @@ function M.compile(bufnr, name, provider, ctx, opts)
end
end
if output_file ~= '' then
local out_dir = vim.fn.fnamemodify(output_file, ':h')
local out_name = vim.fn.fnamemodify(output_file, ':t')
stop_output_watcher(bufnr)
local ow = vim.uv.new_fs_event()
if ow then
s.output_watcher = ow
local last_mtime = 0
local stat = vim.uv.fs_stat(output_file)
if stat then
last_mtime = stat.mtime.sec
end
ow:start(
out_dir,
{},
vim.schedule_wrap(function(err, filename, _events)
if err or vim.fn.fnamemodify(filename or '', ':t') ~= out_name then
return
end
if not vim.api.nvim_buf_is_valid(bufnr) then
stop_output_watcher(bufnr)
return
end
local new_stat = vim.uv.fs_stat(output_file)
if not (new_stat and new_stat.mtime.sec > last_mtime) then
return
end
last_mtime = new_stat.mtime.sec
log.dbg('output updated for buffer %d', bufnr)
vim.notify('[preview.nvim]: compilation complete', vim.log.levels.INFO)
stderr_acc = {}
s.has_errors = false
clear_errors(bufnr, provider)
vim.api.nvim_exec_autocmds('User', {
pattern = 'PreviewCompileSuccess',
data = { bufnr = bufnr, provider = name, output = output_file },
})
end)
)
end
end
s.process = obj
s.provider = name
s.is_reload = true
s.has_errors = false
vim.notify('[preview.nvim]: compiling...', vim.log.levels.INFO)
vim.api.nvim_exec_autocmds('User', {
pattern = 'PreviewCompileStarted',
data = { bufnr = bufnr, provider = name },
@ -360,6 +426,7 @@ function M.compile(bufnr, name, provider, ctx, opts)
end
if result.code == 0 then
log.dbg('compilation succeeded for buffer %d', bufnr)
vim.notify('[preview.nvim]: compilation complete', vim.log.levels.INFO)
clear_errors(bufnr, provider)
vim.api.nvim_exec_autocmds('User', {
pattern = 'PreviewCompileSuccess',
@ -385,6 +452,7 @@ function M.compile(bufnr, name, provider, ctx, opts)
end
else
log.dbg('compilation failed for buffer %d (exit code %d)', bufnr, result.code)
vim.notify('[preview.nvim]: compilation failed', vim.log.levels.ERROR)
handle_errors(bufnr, name, provider, ctx, (result.stdout or '') .. (result.stderr or ''))
vim.api.nvim_exec_autocmds('User', {
pattern = 'PreviewCompileFailed',
@ -403,6 +471,7 @@ function M.compile(bufnr, name, provider, ctx, opts)
s.provider = name
s.is_reload = false
vim.notify('[preview.nvim]: compiling...', vim.log.levels.INFO)
vim.api.nvim_exec_autocmds('User', {
pattern = 'PreviewCompileStarted',
data = { bufnr = bufnr, provider = name },
@ -415,6 +484,7 @@ function M.stop(bufnr)
if not s then
return
end
stop_output_watcher(bufnr)
local obj = s.process
if not obj then
return
@ -480,6 +550,7 @@ function M.toggle(bufnr, name, provider, ctx_builder)
callback = function()
M.stop(bufnr)
stop_open_watcher(bufnr)
stop_output_watcher(bufnr)
if not provider.detach then
close_viewer(bufnr)
end
@ -512,7 +583,6 @@ function M.toggle(bufnr, name, provider, ctx_builder)
log.dbg('watching buffer %d with provider "%s"', bufnr, name)
end
vim.notify('[preview.nvim]: watching with "' .. name .. '"', vim.log.levels.INFO)
M.compile(bufnr, name, provider, ctx_builder(bufnr))
end

View file

@ -15,21 +15,23 @@ end
---@param error_parser fun(output: string, ctx: preview.Context): preview.Diagnostic[]
---@param output string
---@param ctx preview.Context
---@return integer
function M.set(bufnr, name, error_parser, output, ctx)
local ok, diagnostics = pcall(error_parser, output, ctx)
if not ok then
log.dbg('error_parser for "%s" failed: %s', name, diagnostics)
return
return 0
end
if not diagnostics or #diagnostics == 0 then
log.dbg('error_parser for "%s" returned no diagnostics', name)
return
return 0
end
for _, d in ipairs(diagnostics) do
d.source = d.source or name
end
vim.diagnostic.set(ns, bufnr, diagnostics)
log.dbg('set %d diagnostics for buffer %d from provider "%s"', #diagnostics, bufnr, name)
return #diagnostics
end
---@return integer

View file

@ -224,7 +224,7 @@ M.markdown = {
ft = 'markdown',
cmd = { 'pandoc' },
args = function(ctx)
return { ctx.file, '-s', '--embed-resources', '--mathml', '-o', ctx.output }
return { ctx.file, '-s', '--katex', '-o', ctx.output }
end,
output = function(ctx)
return (ctx.file:gsub('%.md$', '.html'))
@ -249,8 +249,7 @@ M.github = {
'gfm',
ctx.file,
'-s',
'--embed-resources',
'--mathml',
'--katex',
'--css',
'https://cdn.jsdelivr.net/gh/pixelbrackets/gfm-stylesheet@master/dist/gfm.css',
'-o',

View file

@ -55,6 +55,14 @@ describe('compiler', function()
end,
})
local notified = false
local orig = vim.notify
vim.notify = function(msg)
if msg:find('compiling') then
notified = true
end
end
local provider = { cmd = { 'echo', 'ok' } }
local ctx = {
bufnr = bufnr,
@ -64,7 +72,9 @@ describe('compiler', function()
}
compiler.compile(bufnr, 'echo', provider, ctx)
vim.notify = orig
assert.is_true(fired)
assert.is_true(notified)
vim.wait(2000, function()
return process_done(bufnr)
@ -269,6 +279,58 @@ describe('compiler', function()
end)
end)
describe('long-running notifications', function()
it('notifies failure on stderr diagnostics', function()
local bufnr = helpers.create_buffer({ 'hello' }, 'text')
vim.api.nvim_buf_set_name(bufnr, '/tmp/preview_test_longrun.txt')
vim.bo[bufnr].modified = false
local notified_fail = false
local orig = vim.notify
vim.notify = function(msg, level)
if msg:find('compilation failed') and level == vim.log.levels.ERROR then
notified_fail = true
end
end
local provider = {
cmd = { 'sh' },
reload = function()
return { 'sh', '-c', 'echo "error: bad input" >&2; sleep 60' }
end,
error_parser = function()
return {
{ lnum = 0, col = 0, message = 'bad input', severity = vim.diagnostic.severity.ERROR },
}
end,
}
local ctx = {
bufnr = bufnr,
file = '/tmp/preview_test_longrun.txt',
root = '/tmp',
ft = 'text',
}
compiler.compile(bufnr, 'testprov', provider, ctx)
vim.wait(3000, function()
return notified_fail
end, 50)
vim.notify = orig
assert.is_true(notified_fail)
local s = compiler._test.state[bufnr]
assert.is_true(s.has_errors)
compiler.stop(bufnr)
vim.wait(2000, function()
return process_done(bufnr)
end, 50)
helpers.delete_buffer(bufnr)
end)
end)
describe('stop', function()
it('does nothing when no process is active', function()
assert.has_no.errors(function()

View file

@ -124,5 +124,47 @@ describe('diagnostic', function()
assert.are.equal(0, #diags)
helpers.delete_buffer(bufnr)
end)
it('returns count of diagnostics set', function()
local bufnr = helpers.create_buffer({ 'line1', 'line2' })
local parser = function()
return {
{ lnum = 0, col = 0, message = 'err1', severity = vim.diagnostic.severity.ERROR },
{ lnum = 1, col = 0, message = 'err2', severity = vim.diagnostic.severity.WARN },
}
end
local ctx = { bufnr = bufnr, file = '/tmp/test.typ', root = '/tmp', ft = 'typst' }
local count = diagnostic.set(bufnr, 'typst', parser, 'errors', ctx)
assert.are.equal(2, count)
helpers.delete_buffer(bufnr)
end)
it('returns 0 on parser failure', function()
local bufnr = helpers.create_buffer({ 'line1' })
local parser = function()
error('boom')
end
local ctx = { bufnr = bufnr, file = '/tmp/test.tex', root = '/tmp', ft = 'tex' }
local count = diagnostic.set(bufnr, 'latexmk', parser, 'error', ctx)
assert.are.equal(0, count)
helpers.delete_buffer(bufnr)
end)
it('returns 0 on empty diagnostics', function()
local bufnr = helpers.create_buffer({ 'line1' })
local parser = function()
return {}
end
local ctx = { bufnr = bufnr, file = '/tmp/test.tex', root = '/tmp', ft = 'tex' }
local count = diagnostic.set(bufnr, 'latexmk', parser, 'error', ctx)
assert.are.equal(0, count)
helpers.delete_buffer(bufnr)
end)
end)
end)

View file

@ -294,13 +294,10 @@ describe('presets', function()
assert.are.same({ 'pandoc' }, presets.markdown.cmd)
end)
it('returns args with standalone, embed-resources, and mathml flags', function()
it('returns args with standalone and katex flags', function()
local args = presets.markdown.args(md_ctx)
assert.is_table(args)
assert.are.same(
{ '/tmp/document.md', '-s', '--embed-resources', '--mathml', '-o', '/tmp/document.html' },
args
)
assert.are.same({ '/tmp/document.md', '-s', '--katex', '-o', '/tmp/document.html' }, args)
end)
it('returns html output path', function()
@ -382,7 +379,7 @@ describe('presets', function()
assert.are.same({ 'pandoc' }, presets.github.cmd)
end)
it('returns args with standalone, embed-resources, mathml, and css flags', function()
it('returns args with standalone, katex, and css flags', function()
local args = presets.github.args(md_ctx)
assert.is_table(args)
assert.are.same({
@ -390,8 +387,7 @@ describe('presets', function()
'gfm',
'/tmp/document.md',
'-s',
'--embed-resources',
'--mathml',
'--katex',
'--css',
'https://cdn.jsdelivr.net/gh/pixelbrackets/gfm-stylesheet@master/dist/gfm.css',
'-o',