From 7ed4b61c988dc1f400bebe634e3b212936f5c96d Mon Sep 17 00:00:00 2001 From: Barrett Ruth <62671086+barrettruth@users.noreply.github.com> Date: Tue, 3 Mar 2026 15:03:48 -0500 Subject: [PATCH 01/35] refactor(commands): derive completion from dispatch table (#15) --- lua/preview/commands.lua | 35 ++++++++++++++++++++++------------- 1 file changed, 22 insertions(+), 13 deletions(-) diff --git a/lua/preview/commands.lua b/lua/preview/commands.lua index f82c726..2c52c5a 100644 --- a/lua/preview/commands.lua +++ b/lua/preview/commands.lua @@ -1,22 +1,22 @@ local M = {} -local subcommands = { 'compile', 'stop', 'clean', 'toggle', 'open', 'status' } - ----@param args string -local function dispatch(args) - local subcmd = args ~= '' and args or 'compile' - - if subcmd == 'compile' then +local handlers = { + compile = function() require('preview').compile() - elseif subcmd == 'stop' then + end, + stop = function() require('preview').stop() - elseif subcmd == 'clean' then + end, + clean = function() require('preview').clean() - elseif subcmd == 'toggle' then + end, + toggle = function() require('preview').toggle() - elseif subcmd == 'open' then + end, + open = function() require('preview').open() - elseif subcmd == 'status' then + end, + status = function() local s = require('preview').status() local parts = {} if s.compiling then @@ -28,6 +28,15 @@ local function dispatch(args) table.insert(parts, 'watching') end vim.notify('[preview.nvim]: ' .. table.concat(parts, ', '), vim.log.levels.INFO) + end, +} + +---@param args string +local function dispatch(args) + local subcmd = args ~= '' and args or 'compile' + local handler = handlers[subcmd] + if handler then + handler() else vim.notify('[preview.nvim]: unknown subcommand: ' .. subcmd, vim.log.levels.ERROR) end @@ -38,7 +47,7 @@ end local function complete(lead) return vim.tbl_filter(function(s) return s:find(lead, 1, true) == 1 - end, subcommands) + end, vim.tbl_keys(handlers)) end function M.setup() From 4c22f84b31ebfee1b8fd59dbb254917582f3985e Mon Sep 17 00:00:00 2001 From: Barrett Ruth <62671086+barrettruth@users.noreply.github.com> Date: Tue, 3 Mar 2026 15:04:03 -0500 Subject: [PATCH 02/35] feat(init): validate provider config eagerly in setup (#16) --- lua/preview/init.lua | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/lua/preview/init.lua b/lua/preview/init.lua index 4a44a33..f4f2831 100644 --- a/lua/preview/init.lua +++ b/lua/preview/init.lua @@ -85,6 +85,20 @@ function M.setup(opts) end end + for ft, provider in pairs(providers) do + local prefix = 'providers.' .. ft + vim.validate(prefix .. '.cmd', provider.cmd, 'table') + vim.validate(prefix .. '.cmd[1]', provider.cmd[1], 'string') + vim.validate(prefix .. '.args', provider.args, { 'table', 'function' }, true) + vim.validate(prefix .. '.cwd', provider.cwd, { 'string', 'function' }, true) + vim.validate(prefix .. '.output', provider.output, { 'string', 'function' }, true) + vim.validate(prefix .. '.error_parser', provider.error_parser, 'function', true) + vim.validate(prefix .. '.errors', provider.errors, function(x) + return x == nil or x == false or x == 'diagnostic' or x == 'quickfix' + end, 'false, "diagnostic", or "quickfix"') + vim.validate(prefix .. '.open', provider.open, { 'boolean', 'table' }, true) + end + config = vim.tbl_deep_extend('force', default_config, { debug = debug, providers = providers, From 99263dec9f33712eacf001b703acff5166a9f6a6 Mon Sep 17 00:00:00 2001 From: Barrett Ruth <62671086+barrettruth@users.noreply.github.com> Date: Tue, 3 Mar 2026 15:12:14 -0500 Subject: [PATCH 03/35] refactor(compiler): resolve output before args (#17) Problem: presets that need the output path in their args function (markdown, github) had to recompute it inline, duplicating the same gsub expression already in the output field. Solution: resolve output_file first in M.compile, then extend ctx with output = output_file into a resolved_ctx before evaluating args and cwd. Presets can now reference ctx.output directly. Add output? to the preview.Context type annotation. --- lua/preview/compiler.lua | 16 +++++++++------- lua/preview/init.lua | 1 + lua/preview/presets.lua | 6 ++---- spec/presets_spec.lua | 2 ++ 4 files changed, 14 insertions(+), 11 deletions(-) diff --git a/lua/preview/compiler.lua b/lua/preview/compiler.lua index 0643ccd..eca9f4d 100644 --- a/lua/preview/compiler.lua +++ b/lua/preview/compiler.lua @@ -53,19 +53,21 @@ function M.compile(bufnr, name, provider, ctx) M.stop(bufnr) end + local output_file = '' + if provider.output then + output_file = eval_string(provider.output, ctx) + end + + local resolved_ctx = vim.tbl_extend('force', ctx, { output = output_file }) + local cmd = vim.list_extend({}, provider.cmd) if provider.args then - vim.list_extend(cmd, eval_list(provider.args, ctx)) + vim.list_extend(cmd, eval_list(provider.args, resolved_ctx)) end local cwd = ctx.root if provider.cwd then - cwd = eval_string(provider.cwd, ctx) - end - - local output_file = '' - if provider.output then - output_file = eval_string(provider.output, ctx) + cwd = eval_string(provider.cwd, resolved_ctx) end if output_file ~= '' then diff --git a/lua/preview/init.lua b/lua/preview/init.lua index f4f2831..2bee03a 100644 --- a/lua/preview/init.lua +++ b/lua/preview/init.lua @@ -19,6 +19,7 @@ ---@field file string ---@field root string ---@field ft string +---@field output? string ---@class preview.Diagnostic ---@field lnum integer diff --git a/lua/preview/presets.lua b/lua/preview/presets.lua index 8b9faab..c1de0df 100644 --- a/lua/preview/presets.lua +++ b/lua/preview/presets.lua @@ -138,8 +138,7 @@ M.markdown = { ft = 'markdown', cmd = { 'pandoc' }, args = function(ctx) - local output = ctx.file:gsub('%.md$', '.html') - return { ctx.file, '-s', '--embed-resources', '-o', output } + return { ctx.file, '-s', '--embed-resources', '-o', ctx.output } end, output = function(ctx) return (ctx.file:gsub('%.md$', '.html')) @@ -158,7 +157,6 @@ M.github = { ft = 'markdown', cmd = { 'pandoc' }, args = function(ctx) - local output = ctx.file:gsub('%.md$', '.html') return { '-f', 'gfm', @@ -168,7 +166,7 @@ M.github = { '--css', 'https://cdn.jsdelivr.net/gh/pixelbrackets/gfm-stylesheet@master/dist/gfm.css', '-o', - output, + ctx.output, } end, output = function(ctx) diff --git a/spec/presets_spec.lua b/spec/presets_spec.lua index 904a4f4..6eaa613 100644 --- a/spec/presets_spec.lua +++ b/spec/presets_spec.lua @@ -150,6 +150,7 @@ describe('presets', function() file = '/tmp/document.md', root = '/tmp', ft = 'markdown', + output = '/tmp/document.html', } it('has ft', function() @@ -233,6 +234,7 @@ describe('presets', function() file = '/tmp/document.md', root = '/tmp', ft = 'markdown', + output = '/tmp/document.html', } it('has ft', function() From bce3cec0e66654eb7b13ad46c5dbe8f9209e72de Mon Sep 17 00:00:00 2001 From: Barrett Ruth <62671086+barrettruth@users.noreply.github.com> Date: Tue, 3 Mar 2026 15:12:28 -0500 Subject: [PATCH 04/35] docs: update help file for recent additions (#18) --- doc/preview.nvim.txt | 22 +++++++++++++++++++--- 1 file changed, 19 insertions(+), 3 deletions(-) diff --git a/doc/preview.nvim.txt b/doc/preview.nvim.txt index 0b44a91..c8bc708 100644 --- a/doc/preview.nvim.txt +++ b/doc/preview.nvim.txt @@ -74,9 +74,16 @@ Provider fields:~ `output` string|function Output file path. If a function, receives a |preview.Context|. - `error_parser` function Receives (stderr, |preview.Context|) + `error_parser` function Receives (output, |preview.Context|) and returns vim.Diagnostic[]. + `errors` false|'diagnostic'|'quickfix' + How parse errors are reported. + `false` suppresses error handling. + `'quickfix'` populates the quickfix + list and opens it. Default: + `'diagnostic'`. + `clean` string[]|function Command to remove build artifacts. If a function, receives a |preview.Context|. @@ -85,7 +92,6 @@ Provider fields:~ successful compilation. `true` uses |vim.ui.open()|. A string[] is run as a command with the output path appended. - Presets default to `{ 'xdg-open' }`. *preview.Context* Context fields:~ @@ -94,6 +100,8 @@ Context fields:~ `file` string Absolute file path. `root` string Project root (git root or file directory). `ft` string Filetype. + `output` string? Resolved output file path (set after `output` + is evaluated, available to `args` functions). Example enabling presets:~ >lua @@ -156,7 +164,8 @@ COMMANDS *preview.nvim-commands* `stop` Kill active compilation for the current buffer. `clean` Run the provider's clean command. `toggle` Toggle auto-compile on save for the current buffer. - `status` Echo compilation status (idle, compiling, toggled). + `open` Open the last compiled output without recompiling. + `status` Echo compilation status (idle, compiling, watching). ============================================================================== API *preview.nvim-api* @@ -175,9 +184,16 @@ preview.toggle({bufnr?}) *preview.toggle()* immediately compiled and automatically recompiled on each save (`BufWritePost`). Call again to stop. +preview.open({bufnr?}) *preview.open()* + Open the last compiled output for the buffer without recompiling. + preview.status({bufnr?}) *preview.status()* Returns a |preview.Status| table. +preview.statusline({bufnr?}) *preview.statusline()* + Returns a short status string for statusline integration: + `'compiling'`, `'watching'`, or `''` (idle). + *preview.Status* Status fields:~ From 62961c854168cec9ae30e52c3e1b2e914248f1f2 Mon Sep 17 00:00:00 2001 From: Barrett Ruth <62671086+barrettruth@users.noreply.github.com> Date: Tue, 3 Mar 2026 16:41:47 -0500 Subject: [PATCH 05/35] feat: unified reload field for live-preview (SSE + long-running watch) (#19) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(reload): add SSE live-reload server module Problem: HTML output from pandoc has no live-reload; the browser must be refreshed manually after each compile. Solution: add lua/preview/reload.lua — a minimal SSE-only TCP server. start() binds 127.0.0.1:5554 and keeps EventSource connections alive; broadcast() pushes a reload event to all clients; inject() appends an EventSource script before (or at EOF) on every compile so pandoc overwrites do not lose the tag. * refactor(presets): add reload field, remove synctex field Problem: the synctex field only handled PDF forward search and left HTML live-preview and typst watch mode unsupported. Solution: add reload = function(ctx) returning { 'typst', 'watch', ctx.file } to typst (long-running watch mode), reload = true to markdown and github (SSE push after each pandoc compile), and remove synctex = true from latex (the -synctex=1 arg in latex.args remains for .synctex.gz generation). * refactor(init): replace synctex field and validation with reload Problem: ProviderConfig still declared synctex and validated it, but the field is being dropped in favour of the general-purpose reload. Solution: replace the synctex annotation and vim.validate call with the reload field, accepting boolean | string[] | function. * feat(compiler): support long-running watch processes and SSE reload Problem: compile() only supports one-shot invocations, requiring a BufWritePost autocmd for watch mode and leaving HTML without live- reload. Solution: resolve_reload_cmd() maps provider.reload (function or table) to a command; when present, compile() spawns it as a long-running process instead of building a one-shot cmd from provider.cmd + args. toggle() detects long-running providers and toggles the process directly instead of registering a BufWritePost autocmd. When reload = true and output is .html, the SSE server is invoked after each successful compile. status() reports is_reload processes as watching, not compiling. stop_all() also stops the SSE server. * fix(compiler): format is_longrunning and annotate is_reload field Problem: stylua required is_longrunning to be on one line; lua-ls warned about undefined field is_reload on preview.Process. Solution: inline the boolean expression and add is_reload? to the preview.Process annotation. * refactor: rename compile/toggle commands to build/watch Problem: `compile` and `toggle` are accurate but unintuitive — `compile` sounds academic and `toggle` says nothing about what it toggles. Solution: rename the public API and `:Preview` subcommands to `build` (one-shot) and `watch` (live preview). Internal compiler functions are unchanged. No aliases for old names — clean break. --- lua/preview/commands.lua | 12 ++-- lua/preview/compiler.lua | 138 +++++++++++++++++++++++++++++++++++++-- lua/preview/init.lua | 11 ++-- lua/preview/presets.lua | 6 ++ lua/preview/reload.lua | 108 ++++++++++++++++++++++++++++++ spec/commands_spec.lua | 6 +- spec/presets_spec.lua | 21 ++++++ spec/reload_spec.lua | 58 ++++++++++++++++ 8 files changed, 340 insertions(+), 20 deletions(-) create mode 100644 lua/preview/reload.lua create mode 100644 spec/reload_spec.lua diff --git a/lua/preview/commands.lua b/lua/preview/commands.lua index 2c52c5a..c91f4de 100644 --- a/lua/preview/commands.lua +++ b/lua/preview/commands.lua @@ -1,8 +1,8 @@ local M = {} local handlers = { - compile = function() - require('preview').compile() + build = function() + require('preview').build() end, stop = function() require('preview').stop() @@ -10,8 +10,8 @@ local handlers = { clean = function() require('preview').clean() end, - toggle = function() - require('preview').toggle() + watch = function() + require('preview').watch() end, open = function() require('preview').open() @@ -33,7 +33,7 @@ local handlers = { ---@param args string local function dispatch(args) - local subcmd = args ~= '' and args or 'compile' + local subcmd = args ~= '' and args or 'build' local handler = handlers[subcmd] if handler then handler() @@ -58,7 +58,7 @@ function M.setup() complete = function(lead) return complete(lead) end, - desc = 'Compile, stop, clean, toggle, open, or check status of document preview', + desc = 'Build, stop, clean, watch, open, or check status of document preview', }) end diff --git a/lua/preview/compiler.lua b/lua/preview/compiler.lua index eca9f4d..e19cbf0 100644 --- a/lua/preview/compiler.lua +++ b/lua/preview/compiler.lua @@ -39,6 +39,18 @@ local function eval_string(val, ctx) return val end +---@param provider preview.ProviderConfig +---@param ctx preview.Context +---@return string[]? +local function resolve_reload_cmd(provider, ctx) + if type(provider.reload) == 'function' then + return provider.reload(ctx) + elseif type(provider.reload) == 'table' then + return vim.list_extend({}, provider.reload) + end + return nil +end + ---@param bufnr integer ---@param name string ---@param provider preview.ProviderConfig @@ -60,11 +72,6 @@ function M.compile(bufnr, name, provider, ctx) local resolved_ctx = vim.tbl_extend('force', ctx, { output = output_file }) - local cmd = vim.list_extend({}, provider.cmd) - if provider.args then - vim.list_extend(cmd, eval_list(provider.args, resolved_ctx)) - end - local cwd = ctx.root if provider.cwd then cwd = eval_string(provider.cwd, resolved_ctx) @@ -74,6 +81,103 @@ function M.compile(bufnr, name, provider, ctx) last_output[bufnr] = output_file end + local reload_cmd = resolve_reload_cmd(provider, resolved_ctx) + + if reload_cmd then + log.dbg( + 'starting long-running process for buffer %d with provider "%s": %s', + bufnr, + name, + table.concat(reload_cmd, ' ') + ) + + local obj = vim.system( + reload_cmd, + { + cwd = cwd, + env = provider.env, + }, + vim.schedule_wrap(function(result) + active[bufnr] = nil + if not vim.api.nvim_buf_is_valid(bufnr) then + return + end + + if result.code ~= 0 then + log.dbg('long-running process failed for buffer %d (exit code %d)', bufnr, result.code) + local errors_mode = provider.errors + if errors_mode == nil then + errors_mode = 'diagnostic' + end + if provider.error_parser and errors_mode then + local output = (result.stdout or '') .. (result.stderr or '') + if errors_mode == 'diagnostic' then + diagnostic.set(bufnr, name, provider.error_parser, output, ctx) + elseif errors_mode == 'quickfix' then + local ok, diagnostics = pcall(provider.error_parser, output, ctx) + if ok and diagnostics and #diagnostics > 0 then + local items = {} + for _, d in ipairs(diagnostics) do + table.insert(items, { + bufnr = bufnr, + lnum = d.lnum + 1, + col = d.col + 1, + text = d.message, + type = d.severity == vim.diagnostic.severity.WARN and 'W' or 'E', + }) + end + vim.fn.setqflist(items, 'r') + vim.cmd('copen') + end + end + end + vim.api.nvim_exec_autocmds('User', { + pattern = 'PreviewCompileFailed', + data = { + bufnr = bufnr, + provider = name, + code = result.code, + stderr = result.stderr or '', + }, + }) + end + end) + ) + + if provider.open and not opened[bufnr] and output_file ~= '' then + if provider.open == true then + vim.ui.open(output_file) + elseif type(provider.open) == 'table' then + local open_cmd = vim.list_extend({}, provider.open) + table.insert(open_cmd, output_file) + vim.system(open_cmd) + end + opened[bufnr] = true + end + + active[bufnr] = { obj = obj, provider = name, output_file = output_file, is_reload = true } + + vim.api.nvim_create_autocmd('BufWipeout', { + buffer = bufnr, + once = true, + callback = function() + M.stop(bufnr) + last_output[bufnr] = nil + end, + }) + + vim.api.nvim_exec_autocmds('User', { + pattern = 'PreviewCompileStarted', + data = { bufnr = bufnr, provider = name }, + }) + return + end + + local cmd = vim.list_extend({}, provider.cmd) + if provider.args then + vim.list_extend(cmd, eval_list(provider.args, resolved_ctx)) + end + log.dbg('compiling buffer %d with provider "%s": %s', bufnr, name, table.concat(cmd, ' ')) local obj = vim.system( @@ -104,6 +208,12 @@ function M.compile(bufnr, name, provider, ctx) pattern = 'PreviewCompileSuccess', data = { bufnr = bufnr, provider = name, output = output_file }, }) + if provider.reload == true and output_file:match('%.html$') then + local r = require('preview.reload') + r.start() + r.inject(output_file) + r.broadcast() + end if provider.open and not opened[bufnr] and output_file ~= '' then if provider.open == true then vim.ui.open(output_file) @@ -198,6 +308,7 @@ function M.stop_all() for bufnr, _ in pairs(watching) do M.unwatch(bufnr) end + require('preview.reload').stop() end ---@param bufnr integer @@ -205,6 +316,19 @@ end ---@param provider preview.ProviderConfig ---@param ctx_builder fun(bufnr: integer): preview.Context function M.toggle(bufnr, name, provider, ctx_builder) + local is_longrunning = type(provider.reload) == 'table' or type(provider.reload) == 'function' + + if is_longrunning then + if active[bufnr] then + M.stop(bufnr) + vim.notify('[preview.nvim]: watching stopped', vim.log.levels.INFO) + else + M.compile(bufnr, name, provider, ctx_builder(bufnr)) + vim.notify('[preview.nvim]: watching with "' .. name .. '"', vim.log.levels.INFO) + end + return + end + if watching[bufnr] then M.unwatch(bufnr) vim.notify('[preview.nvim]: watching stopped', vim.log.levels.INFO) @@ -313,8 +437,8 @@ function M.status(bufnr) local proc = active[bufnr] if proc then return { - compiling = true, - watching = watching[bufnr] ~= nil, + compiling = not proc.is_reload, + watching = watching[bufnr] ~= nil or proc.is_reload == true, provider = proc.provider, output_file = proc.output_file, } diff --git a/lua/preview/init.lua b/lua/preview/init.lua index 2bee03a..bbf70f1 100644 --- a/lua/preview/init.lua +++ b/lua/preview/init.lua @@ -9,6 +9,7 @@ ---@field errors? false|'diagnostic'|'quickfix' ---@field clean? string[]|fun(ctx: preview.Context): string[] ---@field open? boolean|string[] +---@field reload? boolean|string[]|fun(ctx: preview.Context): string[] ---@class preview.Config ---@field debug boolean|string @@ -34,13 +35,14 @@ ---@field obj table ---@field provider string ---@field output_file string +---@field is_reload? boolean ---@class preview ---@field setup fun(opts?: table) ----@field compile fun(bufnr?: integer) +---@field build fun(bufnr?: integer) ---@field stop fun(bufnr?: integer) ---@field clean fun(bufnr?: integer) ----@field toggle fun(bufnr?: integer) +---@field watch fun(bufnr?: integer) ---@field open fun(bufnr?: integer) ---@field status fun(bufnr?: integer): preview.Status ---@field statusline fun(bufnr?: integer): string @@ -98,6 +100,7 @@ function M.setup(opts) return x == nil or x == false or x == 'diagnostic' or x == 'quickfix' end, 'false, "diagnostic", or "quickfix"') vim.validate(prefix .. '.open', provider.open, { 'boolean', 'table' }, true) + vim.validate(prefix .. '.reload', provider.reload, { 'boolean', 'table', 'function' }, true) end config = vim.tbl_deep_extend('force', default_config, { @@ -141,7 +144,7 @@ function M.build_context(bufnr) end ---@param bufnr? integer -function M.compile(bufnr) +function M.build(bufnr) bufnr = bufnr or vim.api.nvim_get_current_buf() local name = M.resolve_provider(bufnr) if not name then @@ -173,7 +176,7 @@ function M.clean(bufnr) end ---@param bufnr? integer -function M.toggle(bufnr) +function M.watch(bufnr) bufnr = bufnr or vim.api.nvim_get_current_buf() local name = M.resolve_provider(bufnr) if not name then diff --git a/lua/preview/presets.lua b/lua/preview/presets.lua index c1de0df..fc20f89 100644 --- a/lua/preview/presets.lua +++ b/lua/preview/presets.lua @@ -107,6 +107,9 @@ M.typst = { return parse_typst(output) end, open = true, + reload = function(ctx) + return { 'typst', 'watch', ctx.file } + end, } ---@type preview.ProviderConfig @@ -117,6 +120,7 @@ M.latex = { return { '-pdf', '-interaction=nonstopmode', + '-synctex=1', '-pdflatex=pdflatex -file-line-error -interaction=nonstopmode %O %S', ctx.file, } @@ -150,6 +154,7 @@ M.markdown = { return { 'rm', '-f', (ctx.file:gsub('%.md$', '.html')) } end, open = true, + reload = true, } ---@type preview.ProviderConfig @@ -179,6 +184,7 @@ M.github = { return { 'rm', '-f', (ctx.file:gsub('%.md$', '.html')) } end, open = true, + reload = true, } return M diff --git a/lua/preview/reload.lua b/lua/preview/reload.lua new file mode 100644 index 0000000..fd64309 --- /dev/null +++ b/lua/preview/reload.lua @@ -0,0 +1,108 @@ +local M = {} + +local PORT = 5554 +local server_handle = nil +local clients = {} + +local function make_script(port) + return '' +end + +function M.start(port) + port = port or PORT + if server_handle then + return + end + local server = vim.uv.new_tcp() + server:bind('127.0.0.1', port) + server:listen(128, function(err) + if err then + return + end + local client = vim.uv.new_tcp() + server:accept(client) + local buf = '' + client:read_start(function(read_err, data) + if read_err or not data then + if not client:is_closing() then + client:close() + end + return + end + buf = buf .. data + if buf:find('\r\n\r\n') or buf:find('\n\n') then + client:read_stop() + local first_line = buf:match('^([^\r\n]+)') + if first_line and first_line:find('/__live/events', 1, true) then + local response = 'HTTP/1.1 200 OK\r\n' + .. 'Content-Type: text/event-stream\r\n' + .. 'Cache-Control: no-cache\r\n' + .. 'Access-Control-Allow-Origin: *\r\n' + .. '\r\n' + client:write(response) + table.insert(clients, client) + else + client:close() + end + end + end) + end) + server_handle = server +end + +function M.stop() + for _, c in ipairs(clients) do + if not c:is_closing() then + c:close() + end + end + clients = {} + if server_handle then + server_handle:close() + server_handle = nil + end +end + +function M.broadcast() + local event = 'event: reload\ndata: {}\n\n' + local alive = {} + for _, c in ipairs(clients) do + if not c:is_closing() then + local ok = pcall(function() + c:write(event) + end) + if ok then + table.insert(alive, c) + end + end + end + clients = alive +end + +function M.inject(path, port) + port = port or PORT + local f = io.open(path, 'r') + if not f then + return + end + local content = f:read('*a') + f:close() + local script = make_script(port) + local new_content, n = content:gsub('', script .. '\n', 1) + if n == 0 then + new_content = content .. '\n' .. script + end + local fw = io.open(path, 'w') + if not fw then + return + end + fw:write(new_content) + fw:close() +end + +return M diff --git a/spec/commands_spec.lua b/spec/commands_spec.lua index 931174f..32da224 100644 --- a/spec/commands_spec.lua +++ b/spec/commands_spec.lua @@ -17,7 +17,7 @@ describe('commands', function() it('does not error on :Preview with no provider', function() require('preview.commands').setup() assert.has_no.errors(function() - vim.cmd('Preview compile') + vim.cmd('Preview build') end) end) @@ -42,10 +42,10 @@ describe('commands', function() end) end) - it('does not error on :Preview toggle with no provider', function() + it('does not error on :Preview watch with no provider', function() require('preview.commands').setup() assert.has_no.errors(function() - vim.cmd('Preview toggle') + vim.cmd('Preview watch') end) end) end) diff --git a/spec/presets_spec.lua b/spec/presets_spec.lua index 6eaa613..8085a3c 100644 --- a/spec/presets_spec.lua +++ b/spec/presets_spec.lua @@ -37,6 +37,18 @@ describe('presets', function() assert.is_true(presets.typst.open) end) + it('has reload as a function', function() + assert.is_function(presets.typst.reload) + end) + + it('reload returns typst watch command', function() + local result = presets.typst.reload(ctx) + assert.is_table(result) + assert.are.equal('typst', result[1]) + assert.are.equal('watch', result[2]) + assert.are.equal(ctx.file, result[3]) + end) + it('parses errors from stderr', function() local stderr = table.concat({ 'main.typ:5:23: error: unexpected token', @@ -84,6 +96,7 @@ describe('presets', function() assert.are.same({ '-pdf', '-interaction=nonstopmode', + '-synctex=1', '-pdflatex=pdflatex -file-line-error -interaction=nonstopmode %O %S', '/tmp/document.tex', }, args) @@ -186,6 +199,10 @@ describe('presets', function() assert.is_true(presets.markdown.open) end) + it('has reload enabled for SSE', function() + assert.is_true(presets.markdown.reload) + end) + it('parses YAML metadata errors with multiline message', function() local output = table.concat({ 'Error parsing YAML metadata at "/tmp/test.md" (line 1, column 1):', @@ -290,6 +307,10 @@ describe('presets', function() assert.is_true(presets.github.open) end) + it('has reload enabled for SSE', function() + assert.is_true(presets.github.reload) + end) + it('parses YAML metadata errors with multiline message', function() local output = table.concat({ 'Error parsing YAML metadata at "/tmp/test.md" (line 1, column 1):', diff --git a/spec/reload_spec.lua b/spec/reload_spec.lua new file mode 100644 index 0000000..12b7aac --- /dev/null +++ b/spec/reload_spec.lua @@ -0,0 +1,58 @@ +describe('reload', function() + local reload + + before_each(function() + package.loaded['preview.reload'] = nil + reload = require('preview.reload') + end) + + after_each(function() + reload.stop() + end) + + describe('inject', function() + it('injects script before ', function() + local path = os.tmpname() + local f = io.open(path, 'w') + f:write('

hello

') + f:close() + + reload.inject(path) + + local fr = io.open(path, 'r') + local content = fr:read('*a') + fr:close() + os.remove(path) + + assert.is_truthy(content:find('EventSource', 1, true)) + local script_pos = content:find('EventSource', 1, true) + local body_pos = content:find('', 1, true) + assert.is_truthy(body_pos) + assert.is_true(script_pos < body_pos) + end) + + it('appends script when no ', function() + local path = os.tmpname() + local f = io.open(path, 'w') + f:write('

hello

') + f:close() + + reload.inject(path) + + local fr = io.open(path, 'r') + local content = fr:read('*a') + fr:close() + os.remove(path) + + assert.is_truthy(content:find('EventSource', 1, true)) + end) + end) + + describe('broadcast', function() + it('does not error with no clients', function() + assert.has_no.errors(function() + reload.broadcast() + end) + end) + end) +end) From e661ea78e836eca09e01af1ccc24e94258817e9b Mon Sep 17 00:00:00 2001 From: Barrett Ruth <62671086+barrettruth@users.noreply.github.com> Date: Tue, 3 Mar 2026 17:46:04 -0500 Subject: [PATCH 06/35] fix(reload): bind SSE server to port 0 for OS-assigned port (#21) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Problem: the SSE reload server hardcoded port 5554, causing silent failure when that port was already in use. bind() would fail but its return value was never checked; listen() would also error and silently drop via the if err then return end guard. inject() still wrote the dead EventSource URL into the HTML, so the browser would connect to whatever was on 5554 — or nothing — and live reload would silently stop working. Solution: bind to port or 0 so the OS assigns a free port, then call getsockname() after bind to capture the actual port into actual_port. inject() reads actual_port in preference to the hardcoded constant, and stop() resets it. PORT = 5554 is kept only as a last-resort fallback in inject() if actual_port is unset. --- lua/preview/reload.lua | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/lua/preview/reload.lua b/lua/preview/reload.lua index fd64309..d2c1de9 100644 --- a/lua/preview/reload.lua +++ b/lua/preview/reload.lua @@ -2,6 +2,7 @@ local M = {} local PORT = 5554 local server_handle = nil +local actual_port = nil local clients = {} local function make_script(port) @@ -14,12 +15,15 @@ local function make_script(port) end function M.start(port) - port = port or PORT if server_handle then return end local server = vim.uv.new_tcp() - server:bind('127.0.0.1', port) + server:bind('127.0.0.1', port or 0) + local sockname = server:getsockname() + if sockname then + actual_port = sockname.port + end server:listen(128, function(err) if err then return @@ -66,6 +70,7 @@ function M.stop() server_handle:close() server_handle = nil end + actual_port = nil end function M.broadcast() @@ -85,7 +90,7 @@ function M.broadcast() end function M.inject(path, port) - port = port or PORT + port = actual_port or port or PORT local f = io.open(path, 'r') if not f then return From ec922a35645030ed29f4e7c528ff0f505e8957a8 Mon Sep 17 00:00:00 2001 From: Barrett Ruth <62671086+barrettruth@users.noreply.github.com> Date: Tue, 3 Mar 2026 17:59:58 -0500 Subject: [PATCH 07/35] fix(compiler): respect provider open config in M.open() (#23) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(reload): bind SSE server to port 0 for OS-assigned port Problem: the SSE reload server hardcoded port 5554, causing silent failure when that port was already in use. bind() would fail but its return value was never checked; listen() would also error and silently drop via the if err then return end guard. inject() still wrote the dead EventSource URL into the HTML, so the browser would connect to whatever was on 5554 — or nothing — and live reload would silently stop working. Solution: bind to port or 0 so the OS assigns a free port, then call getsockname() after bind to capture the actual port into actual_port. inject() reads actual_port in preference to the hardcoded constant, and stop() resets it. PORT = 5554 is kept only as a last-resort fallback in inject() if actual_port is unset. * fix(compiler): resolve output into ctx before evaluating clean command Problem: M.clean() passes the raw ctx (no output field) to the provider's clean function. Built-in presets work around this by recomputing the output path inline, but custom providers using ctx.output in their clean function receive nil. Solution: resolve output_file from provider.output before eval, extend ctx into resolved_ctx with the output field, and use resolved_ctx when evaluating clean and cwd — consistent with how M.compile() handles args. --- lua/preview/compiler.lua | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/lua/preview/compiler.lua b/lua/preview/compiler.lua index e19cbf0..6f9b312 100644 --- a/lua/preview/compiler.lua +++ b/lua/preview/compiler.lua @@ -396,10 +396,16 @@ function M.clean(bufnr, name, provider, ctx) return end - local cmd = eval_list(provider.clean, ctx) - local cwd = ctx.root + local output_file = '' + if provider.output then + output_file = eval_string(provider.output, ctx) + end + local resolved_ctx = vim.tbl_extend('force', ctx, { output = output_file }) + + local cmd = eval_list(provider.clean, resolved_ctx) + local cwd = resolved_ctx.root if provider.cwd then - cwd = eval_string(provider.cwd, ctx) + cwd = eval_string(provider.cwd, resolved_ctx) end log.dbg('cleaning buffer %d with provider "%s": %s', bufnr, name, table.concat(cmd, ' ')) From 54ef0c3c99f505098252bbb9e37a4b022b0d5ec8 Mon Sep 17 00:00:00 2001 From: Barrett Ruth <62671086+barrettruth@users.noreply.github.com> Date: Wed, 4 Mar 2026 00:17:10 -0500 Subject: [PATCH 08/35] fix(compiler): guard active entry before clearing in process callback (#22) * fix(compiler): guard active entry before clearing in process callback Problem: when M.compile() is called while a previous process is still running, the old process's vim.schedule_wrap callback unconditionally sets active[bufnr] = nil, wiping the new process from the tracking table. status() incorrectly returns idle and stop() becomes a no-op against the still-running process. Solution: capture obj as an upvalue in each callback and only clear active[bufnr] if it still points to the same process object. * fix(compiler): hoist obj declaration before vim.system closure Problem: lua-language-server flagged obj as an undefined global in both vim.schedule_wrap callbacks because local obj = vim.system(...) puts the variable out of scope inside the closure at declaration time. At runtime the guard active[bufnr].obj == obj evaluated obj as nil, so the clear was always skipped and the process remained tracked indefinitely. Solution: split into local obj / obj = vim.system(...) so the upvalue is in scope when the closure is defined. --- lua/preview/compiler.lua | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/lua/preview/compiler.lua b/lua/preview/compiler.lua index 6f9b312..ee55604 100644 --- a/lua/preview/compiler.lua +++ b/lua/preview/compiler.lua @@ -91,14 +91,17 @@ function M.compile(bufnr, name, provider, ctx) table.concat(reload_cmd, ' ') ) - local obj = vim.system( + local obj + obj = vim.system( reload_cmd, { cwd = cwd, env = provider.env, }, vim.schedule_wrap(function(result) - active[bufnr] = nil + if active[bufnr] and active[bufnr].obj == obj then + active[bufnr] = nil + end if not vim.api.nvim_buf_is_valid(bufnr) then return end @@ -180,14 +183,17 @@ function M.compile(bufnr, name, provider, ctx) log.dbg('compiling buffer %d with provider "%s": %s', bufnr, name, table.concat(cmd, ' ')) - local obj = vim.system( + local obj + obj = vim.system( cmd, { cwd = cwd, env = provider.env, }, vim.schedule_wrap(function(result) - active[bufnr] = nil + if active[bufnr] and active[bufnr].obj == obj then + active[bufnr] = nil + end if not vim.api.nvim_buf_is_valid(bufnr) then return end From da3e3e4249eaabfe544934348f388023c06b4a4e Mon Sep 17 00:00:00 2001 From: Barrett Ruth <62671086+barrettruth@users.noreply.github.com> Date: Wed, 4 Mar 2026 00:20:41 -0500 Subject: [PATCH 09/35] fix(compiler): check output exists before opening from long-running process (#24) * fix(compiler): check output exists before opening from long-running process Problem: for long-running processes (typst watch), M.compile() calls the opener immediately after vim.system() returns, before the process has produced any output. On first run the output file does not exist yet, so the opener is called on a nonexistent path. Solution: guard the open block with vim.uv.fs_stat so it only fires if the output file already exists at spawn time. * style(compiler): reformat long condition for stylua --- lua/preview/compiler.lua | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/lua/preview/compiler.lua b/lua/preview/compiler.lua index ee55604..8555881 100644 --- a/lua/preview/compiler.lua +++ b/lua/preview/compiler.lua @@ -147,7 +147,12 @@ function M.compile(bufnr, name, provider, ctx) end) ) - if provider.open and not opened[bufnr] and output_file ~= '' then + if + provider.open + and not opened[bufnr] + and output_file ~= '' + and vim.uv.fs_stat(output_file) + then if provider.open == true then vim.ui.open(output_file) elseif type(provider.open) == 'table' then From 4732696a623683be8ebb605125e2c008280f0c2c Mon Sep 17 00:00:00 2001 From: Barrett Ruth <62671086+barrettruth@users.noreply.github.com> Date: Wed, 4 Mar 2026 02:18:28 -0500 Subject: [PATCH 10/35] fix(compiler): one-shot builds and :Preview open provider config (#25) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(compiler): skip reload command for one-shot builds Problem: compile() unconditionally resolved the reload command, so :Preview build on a provider with reload = function/table (e.g. typst) would start a long-running process (typst watch) instead of a one-shot compile (typst compile). Error diagnostics were also lost because typst watch does not exit non-zero on input errors. Solution: add an opts parameter to compile() with a oneshot flag. M.build() passes { oneshot = true } so resolve_reload_cmd() is skipped and the one-shot path is always taken. toggle() continues to call compile() without the flag, preserving long-running behavior for watch mode. * fix(compiler): respect provider open config in M.open() Problem: :Preview open always called vim.ui.open regardless of the provider's open field. Providers configured with a custom opener (e.g. open = { 'sioyek', '--new-instance' }) were ignored, so the first build opened with sioyek but :Preview open fell back to the system default. Solution: init.lua resolves the provider's open config and passes it to compiler.open(). If open_config is a table, the custom command is spawned with the output path appended. Otherwise vim.ui.open is used as before. * fix: normalize notify prefix to [preview.nvim]: everywhere Problem: some vim.notify calls used [preview.nvim] (no colon) while others used [preview.nvim]: — inconsistent with the log module format. Solution: add the missing colon and space to all notify calls in init.lua and compiler.lua so every user-facing message matches the [preview.nvim]: prefix used by the log module. * ci: format --- lua/preview/compiler.lua | 28 +++++++++++++++++++++------- lua/preview/init.lua | 14 ++++++++------ 2 files changed, 29 insertions(+), 13 deletions(-) diff --git a/lua/preview/compiler.lua b/lua/preview/compiler.lua index 8555881..4c78574 100644 --- a/lua/preview/compiler.lua +++ b/lua/preview/compiler.lua @@ -55,7 +55,9 @@ end ---@param name string ---@param provider preview.ProviderConfig ---@param ctx preview.Context -function M.compile(bufnr, name, provider, ctx) +function M.compile(bufnr, name, provider, ctx, opts) + opts = opts or {} + if vim.bo[bufnr].modified then vim.cmd('silent! update') end @@ -81,7 +83,10 @@ function M.compile(bufnr, name, provider, ctx) last_output[bufnr] = output_file end - local reload_cmd = resolve_reload_cmd(provider, resolved_ctx) + local reload_cmd + if not opts.oneshot then + reload_cmd = resolve_reload_cmd(provider, resolved_ctx) + end if reload_cmd then log.dbg( @@ -403,7 +408,10 @@ end ---@param ctx preview.Context function M.clean(bufnr, name, provider, ctx) if not provider.clean then - vim.notify('[preview.nvim] provider "' .. name .. '" has no clean command', vim.log.levels.WARN) + vim.notify( + '[preview.nvim]: provider "' .. name .. '" has no clean command', + vim.log.levels.WARN + ) return end @@ -427,10 +435,10 @@ function M.clean(bufnr, name, provider, ctx) vim.schedule_wrap(function(result) if result.code == 0 then log.dbg('clean succeeded for buffer %d', bufnr) - vim.notify('[preview.nvim] clean complete', vim.log.levels.INFO) + vim.notify('[preview.nvim]: clean complete', vim.log.levels.INFO) else log.dbg('clean failed for buffer %d (exit code %d)', bufnr, result.code) - vim.notify('[preview.nvim] clean failed: ' .. (result.stderr or ''), vim.log.levels.ERROR) + vim.notify('[preview.nvim]: clean failed: ' .. (result.stderr or ''), vim.log.levels.ERROR) end end) ) @@ -438,13 +446,19 @@ end ---@param bufnr integer ---@return boolean -function M.open(bufnr) +function M.open(bufnr, open_config) local output = last_output[bufnr] if not output then log.dbg('no last output file for buffer %d', bufnr) return false end - vim.ui.open(output) + if type(open_config) == 'table' then + local open_cmd = vim.list_extend({}, open_config) + table.insert(open_cmd, output) + vim.system(open_cmd) + else + vim.ui.open(output) + end return true end diff --git a/lua/preview/init.lua b/lua/preview/init.lua index bbf70f1..acceea5 100644 --- a/lua/preview/init.lua +++ b/lua/preview/init.lua @@ -148,12 +148,12 @@ function M.build(bufnr) bufnr = bufnr or vim.api.nvim_get_current_buf() local name = M.resolve_provider(bufnr) if not name then - vim.notify('[preview.nvim] no provider configured for this filetype', vim.log.levels.WARN) + vim.notify('[preview.nvim]: no provider configured for this filetype', vim.log.levels.WARN) return end local ctx = M.build_context(bufnr) local provider = config.providers[name] - compiler.compile(bufnr, name, provider, ctx) + compiler.compile(bufnr, name, provider, ctx, { oneshot = true }) end ---@param bufnr? integer @@ -167,7 +167,7 @@ function M.clean(bufnr) bufnr = bufnr or vim.api.nvim_get_current_buf() local name = M.resolve_provider(bufnr) if not name then - vim.notify('[preview.nvim] no provider configured for this filetype', vim.log.levels.WARN) + vim.notify('[preview.nvim]: no provider configured for this filetype', vim.log.levels.WARN) return end local ctx = M.build_context(bufnr) @@ -180,7 +180,7 @@ function M.watch(bufnr) bufnr = bufnr or vim.api.nvim_get_current_buf() local name = M.resolve_provider(bufnr) if not name then - vim.notify('[preview.nvim] no provider configured for this filetype', vim.log.levels.WARN) + vim.notify('[preview.nvim]: no provider configured for this filetype', vim.log.levels.WARN) return end local provider = config.providers[name] @@ -190,8 +190,10 @@ end ---@param bufnr? integer function M.open(bufnr) bufnr = bufnr or vim.api.nvim_get_current_buf() - if not compiler.open(bufnr) then - vim.notify('[preview.nvim] no output file available for this buffer', vim.log.levels.WARN) + local name = M.resolve_provider(bufnr) + local open_config = name and config.providers[name] and config.providers[name].open + if not compiler.open(bufnr, open_config) then + vim.notify('[preview.nvim]: no output file available for this buffer', vim.log.levels.WARN) end end From 75b855438a000586fadbc24b0e832cfde5f05b4c Mon Sep 17 00:00:00 2001 From: Barrett Ruth <62671086+barrettruth@users.noreply.github.com> Date: Wed, 4 Mar 2026 13:16:01 -0500 Subject: [PATCH 11/35] refactor: simplify command surface (#28) * refactor: rename build to compile and watch to toggle in public API Problem: the code used build/watch while the help file already documented compile/toggle, creating a confusing mismatch. Solution: rename M.build() to M.compile() and M.watch() to M.toggle() in init.lua, update handler keys in commands.lua, and update the test file to match. * refactor(commands): make toggle the default subcommand Problem: bare :Preview ran a one-shot compile, but users reaching for a "preview" plugin expect it to start previewing (i.e. watch mode). Solution: change the fallback subcommand from compile to toggle so :Preview starts/stops auto-compile on save. * refactor(commands): remove stop subcommand Problem: :Preview stop had a subtle distinction from toggle-off (kill process but keep autocmd) that nobody reaches for deliberately from the command line. Solution: remove stop from the command dispatch table. The Lua API require('preview').stop() remains as a programmatic escape hatch. * docs: update help file for new command surface and document reload Problem: the help file listed compile as the default subcommand, still included the stop subcommand, omitted the reload provider field, and had a misleading claim about shipping with zero defaults. Solution: make toggle the default in the commands section, remove stop from subcommands, add reload to provider fields, fix the introduction text, reorder API entries to match new primacy, and add an output path override example addressing #26/#27. --- doc/preview.nvim.txt | 47 ++++++++++++++++++++++++++++------------ lua/preview/commands.lua | 15 +++++-------- lua/preview/init.lua | 8 +++---- spec/commands_spec.lua | 15 ++++--------- 4 files changed, 47 insertions(+), 38 deletions(-) diff --git a/doc/preview.nvim.txt b/doc/preview.nvim.txt index c8bc708..a3d4981 100644 --- a/doc/preview.nvim.txt +++ b/doc/preview.nvim.txt @@ -10,8 +10,8 @@ preview.nvim is an extensible framework for compiling documents asynchronously in Neovim. It provides a unified interface for any compilation workflow — LaTeX, Typst, Markdown, or anything else with a CLI compiler. -The plugin ships with zero provider defaults. Users must explicitly configure -their compiler commands. preview.nvim is purely an orchestration framework. +The plugin ships with opt-in presets for common tools (Typst, LaTeX, Pandoc) +and supports fully custom providers. See |preview.nvim-presets|. ============================================================================== REQUIREMENTS *preview.nvim-requirements* @@ -93,6 +93,14 @@ Provider fields:~ |vim.ui.open()|. A string[] is run as a command with the output path appended. + `reload` boolean|string[]|function + Reload the output after recompilation. + `true` uses a built-in SSE server for + HTML files. A string[] is run as a + command. If a function, receives a + |preview.Context| and returns a + string[]. + *preview.Context* Context fields:~ @@ -115,6 +123,17 @@ Example overriding a preset field:~ }) < +Example overriding the output path (e.g. latexmk `$out_dir`):~ +>lua + require('preview').setup({ + latex = { + output = function(ctx) + return 'build/' .. vim.fn.fnamemodify(ctx.file, ':t:r') .. '.pdf' + end, + }, + }) +< + Example with a fully custom provider (key is not a preset name):~ >lua require('preview').setup({ @@ -160,30 +179,30 @@ COMMANDS *preview.nvim-commands* Subcommands:~ - `compile` Compile the current buffer (default if omitted). - `stop` Kill active compilation for the current buffer. + `toggle` Toggle auto-compile on save (default if omitted). + `compile` One-shot compile of the current buffer. `clean` Run the provider's clean command. - `toggle` Toggle auto-compile on save for the current buffer. `open` Open the last compiled output without recompiling. `status` Echo compilation status (idle, compiling, watching). ============================================================================== API *preview.nvim-api* -preview.compile({bufnr?}) *preview.compile()* - Compile the document in the given buffer (default: current). - -preview.stop({bufnr?}) *preview.stop()* - Kill the active compilation process for the buffer. - -preview.clean({bufnr?}) *preview.clean()* - Run the provider's clean command for the buffer. - preview.toggle({bufnr?}) *preview.toggle()* Toggle auto-compile for the buffer. When enabled, the buffer is immediately compiled and automatically recompiled on each save (`BufWritePost`). Call again to stop. +preview.compile({bufnr?}) *preview.compile()* + One-shot compile the document in the given buffer (default: current). + +preview.stop({bufnr?}) *preview.stop()* + Kill the active compilation process for the buffer. Programmatic + escape hatch — not exposed as a subcommand. + +preview.clean({bufnr?}) *preview.clean()* + Run the provider's clean command for the buffer. + preview.open({bufnr?}) *preview.open()* Open the last compiled output for the buffer without recompiling. diff --git a/lua/preview/commands.lua b/lua/preview/commands.lua index c91f4de..a2e0470 100644 --- a/lua/preview/commands.lua +++ b/lua/preview/commands.lua @@ -1,17 +1,14 @@ local M = {} local handlers = { - build = function() - require('preview').build() - end, - stop = function() - require('preview').stop() + compile = function() + require('preview').compile() end, clean = function() require('preview').clean() end, - watch = function() - require('preview').watch() + toggle = function() + require('preview').toggle() end, open = function() require('preview').open() @@ -33,7 +30,7 @@ local handlers = { ---@param args string local function dispatch(args) - local subcmd = args ~= '' and args or 'build' + local subcmd = args ~= '' and args or 'toggle' local handler = handlers[subcmd] if handler then handler() @@ -58,7 +55,7 @@ function M.setup() complete = function(lead) return complete(lead) end, - desc = 'Build, stop, clean, watch, open, or check status of document preview', + desc = 'Toggle, compile, clean, open, or check status of document preview', }) end diff --git a/lua/preview/init.lua b/lua/preview/init.lua index acceea5..421ba65 100644 --- a/lua/preview/init.lua +++ b/lua/preview/init.lua @@ -39,10 +39,10 @@ ---@class preview ---@field setup fun(opts?: table) ----@field build fun(bufnr?: integer) +---@field compile fun(bufnr?: integer) ---@field stop fun(bufnr?: integer) ---@field clean fun(bufnr?: integer) ----@field watch fun(bufnr?: integer) +---@field toggle fun(bufnr?: integer) ---@field open fun(bufnr?: integer) ---@field status fun(bufnr?: integer): preview.Status ---@field statusline fun(bufnr?: integer): string @@ -144,7 +144,7 @@ function M.build_context(bufnr) end ---@param bufnr? integer -function M.build(bufnr) +function M.compile(bufnr) bufnr = bufnr or vim.api.nvim_get_current_buf() local name = M.resolve_provider(bufnr) if not name then @@ -176,7 +176,7 @@ function M.clean(bufnr) end ---@param bufnr? integer -function M.watch(bufnr) +function M.toggle(bufnr) bufnr = bufnr or vim.api.nvim_get_current_buf() local name = M.resolve_provider(bufnr) if not name then diff --git a/spec/commands_spec.lua b/spec/commands_spec.lua index 32da224..5cca5a2 100644 --- a/spec/commands_spec.lua +++ b/spec/commands_spec.lua @@ -14,17 +14,10 @@ describe('commands', function() end) describe('dispatch', function() - it('does not error on :Preview with no provider', function() + it('does not error on :Preview compile with no provider', function() require('preview.commands').setup() assert.has_no.errors(function() - vim.cmd('Preview build') - end) - end) - - it('does not error on :Preview stop', function() - require('preview.commands').setup() - assert.has_no.errors(function() - vim.cmd('Preview stop') + vim.cmd('Preview compile') end) end) @@ -42,10 +35,10 @@ describe('commands', function() end) end) - it('does not error on :Preview watch with no provider', function() + it('does not error on :Preview toggle with no provider', function() require('preview.commands').setup() assert.has_no.errors(function() - vim.cmd('Preview watch') + vim.cmd('Preview toggle') end) end) end) From c94df7c5d080984538f1ab028c19b34b2be95bac Mon Sep 17 00:00:00 2001 From: Barrett Ruth <62671086+barrettruth@users.noreply.github.com> Date: Wed, 4 Mar 2026 14:02:16 -0500 Subject: [PATCH 12/35] fix: lifecycle cleanup and defensive runtime checks (#29) * fix(commands): register VimLeavePre to call stop_all Problem: spawned compiler processes and watching autocmds were never cleaned up when Neovim exited, leaving orphaned processes running. Solution: register a VimLeavePre autocmd in commands setup that calls compiler.stop_all(), which kills active processes, unwatches all buffers, and stops the reload server. * fix(compiler): replace BufWipeout with BufUnload Problem: cleanup autocmds used BufWipeout, which only fires for :bwipeout. The common :bdelete path (used by most buffer managers and nvim_buf_delete) fires BufUnload but not BufWipeout, so processes and watches leaked on normal buffer deletion. Solution: switch all three cleanup autocmds from BufWipeout to BufUnload, which fires for both :bdelete and :bwipeout. * fix(init): guard against unnamed buffer in public API Problem: calling compile/toggle/clean/open on an unsaved scratch buffer passed an empty string as ctx.file, producing nonsensical output paths like ".pdf" and silently passing empty strings to compiler binaries. Solution: add an early return with a WARN notification in compile, toggle, clean, and open when the buffer has no file name. * fix(compiler): add fs_stat check to one-shot open path Problem: the long-running process path already guarded opens with vim.uv.fs_stat(), but the one-shot compile path and M.open() did not. Compilation can exit 0 and produce no output, and output files can be externally deleted between compile and open. Solution: add the same fs_stat guard to the one-shot open branch and to M.open() before attempting to launch the viewer. * fix(compiler): check executable before spawning process Problem: if a configured binary was missing or not in PATH, vim.system would fail silently or with a cryptic OS error. The user had no actionable feedback without running :checkhealth. Solution: check vim.fn.executable() at the start of M.compile() and notify with an ERROR-level message pointing to :checkhealth preview if the binary is not found. * fix(compiler): reformat one-shot open condition for line length Problem: the added fs_stat condition exceeded stylua's line length limit on the one-shot open guard. Solution: split the boolean condition across multiple lines to match the project's stylua formatting rules. --- lua/preview/commands.lua | 6 +++++ lua/preview/compiler.lua | 25 ++++++++++++++--- lua/preview/init.lua | 16 +++++++++++ spec/commands_spec.lua | 13 +++++++++ spec/compiler_spec.lua | 29 ++++++++++++++++++++ spec/init_spec.lua | 58 ++++++++++++++++++++++++++++++++++++++++ 6 files changed, 143 insertions(+), 4 deletions(-) diff --git a/lua/preview/commands.lua b/lua/preview/commands.lua index a2e0470..97a13f7 100644 --- a/lua/preview/commands.lua +++ b/lua/preview/commands.lua @@ -57,6 +57,12 @@ function M.setup() end, desc = 'Toggle, compile, clean, open, or check status of document preview', }) + + vim.api.nvim_create_autocmd('VimLeavePre', { + callback = function() + require('preview.compiler').stop_all() + end, + }) end return M diff --git a/lua/preview/compiler.lua b/lua/preview/compiler.lua index 4c78574..74d6070 100644 --- a/lua/preview/compiler.lua +++ b/lua/preview/compiler.lua @@ -58,6 +58,14 @@ end function M.compile(bufnr, name, provider, ctx, opts) opts = opts or {} + if vim.fn.executable(provider.cmd[1]) ~= 1 then + vim.notify( + '[preview.nvim]: "' .. provider.cmd[1] .. '" is not executable (run :checkhealth preview)', + vim.log.levels.ERROR + ) + return + end + if vim.bo[bufnr].modified then vim.cmd('silent! update') end @@ -170,7 +178,7 @@ function M.compile(bufnr, name, provider, ctx, opts) active[bufnr] = { obj = obj, provider = name, output_file = output_file, is_reload = true } - vim.api.nvim_create_autocmd('BufWipeout', { + vim.api.nvim_create_autocmd('BufUnload', { buffer = bufnr, once = true, callback = function() @@ -230,7 +238,12 @@ function M.compile(bufnr, name, provider, ctx, opts) r.inject(output_file) r.broadcast() end - if provider.open and not opened[bufnr] and output_file ~= '' then + if + provider.open + and not opened[bufnr] + and output_file ~= '' + and vim.uv.fs_stat(output_file) + then if provider.open == true then vim.ui.open(output_file) elseif type(provider.open) == 'table' then @@ -279,7 +292,7 @@ function M.compile(bufnr, name, provider, ctx, opts) active[bufnr] = { obj = obj, provider = name, output_file = output_file } - vim.api.nvim_create_autocmd('BufWipeout', { + vim.api.nvim_create_autocmd('BufUnload', { buffer = bufnr, once = true, callback = function() @@ -374,7 +387,7 @@ function M.toggle(bufnr, name, provider, ctx_builder) log.dbg('watching buffer %d with provider "%s"', bufnr, name) vim.notify('[preview.nvim]: watching with "' .. name .. '"', vim.log.levels.INFO) - vim.api.nvim_create_autocmd('BufWipeout', { + vim.api.nvim_create_autocmd('BufUnload', { buffer = bufnr, once = true, callback = function() @@ -452,6 +465,10 @@ function M.open(bufnr, open_config) log.dbg('no last output file for buffer %d', bufnr) return false end + if not vim.uv.fs_stat(output) then + log.dbg('output file no longer exists for buffer %d: %s', bufnr, output) + return false + end if type(open_config) == 'table' then local open_cmd = vim.list_extend({}, open_config) table.insert(open_cmd, output) diff --git a/lua/preview/init.lua b/lua/preview/init.lua index 421ba65..fd54d71 100644 --- a/lua/preview/init.lua +++ b/lua/preview/init.lua @@ -146,6 +146,10 @@ end ---@param bufnr? integer function M.compile(bufnr) bufnr = bufnr or vim.api.nvim_get_current_buf() + if vim.api.nvim_buf_get_name(bufnr) == '' then + vim.notify('[preview.nvim]: buffer has no file name', vim.log.levels.WARN) + return + end local name = M.resolve_provider(bufnr) if not name then vim.notify('[preview.nvim]: no provider configured for this filetype', vim.log.levels.WARN) @@ -165,6 +169,10 @@ end ---@param bufnr? integer function M.clean(bufnr) bufnr = bufnr or vim.api.nvim_get_current_buf() + if vim.api.nvim_buf_get_name(bufnr) == '' then + vim.notify('[preview.nvim]: buffer has no file name', vim.log.levels.WARN) + return + end local name = M.resolve_provider(bufnr) if not name then vim.notify('[preview.nvim]: no provider configured for this filetype', vim.log.levels.WARN) @@ -178,6 +186,10 @@ end ---@param bufnr? integer function M.toggle(bufnr) bufnr = bufnr or vim.api.nvim_get_current_buf() + if vim.api.nvim_buf_get_name(bufnr) == '' then + vim.notify('[preview.nvim]: buffer has no file name', vim.log.levels.WARN) + return + end local name = M.resolve_provider(bufnr) if not name then vim.notify('[preview.nvim]: no provider configured for this filetype', vim.log.levels.WARN) @@ -190,6 +202,10 @@ end ---@param bufnr? integer function M.open(bufnr) bufnr = bufnr or vim.api.nvim_get_current_buf() + if vim.api.nvim_buf_get_name(bufnr) == '' then + vim.notify('[preview.nvim]: buffer has no file name', vim.log.levels.WARN) + return + end local name = M.resolve_provider(bufnr) local open_config = name and config.providers[name] and config.providers[name].open if not compiler.open(bufnr, open_config) then diff --git a/spec/commands_spec.lua b/spec/commands_spec.lua index 5cca5a2..4e12e5d 100644 --- a/spec/commands_spec.lua +++ b/spec/commands_spec.lua @@ -11,6 +11,19 @@ describe('commands', function() local cmds = vim.api.nvim_get_commands({}) assert.is_not_nil(cmds.Preview) end) + + it('registers VimLeavePre autocmd', function() + require('preview.commands').setup() + local aus = vim.api.nvim_get_autocmds({ event = 'VimLeavePre' }) + local found = false + for _, au in ipairs(aus) do + if au.callback then + found = true + break + end + end + assert.is_true(found) + end) end) describe('dispatch', function() diff --git a/spec/compiler_spec.lua b/spec/compiler_spec.lua index 2189347..cd1dd9f 100644 --- a/spec/compiler_spec.lua +++ b/spec/compiler_spec.lua @@ -99,6 +99,35 @@ describe('compiler', function() helpers.delete_buffer(bufnr) end) + it('notifies and returns when binary is not executable', function() + local bufnr = helpers.create_buffer({ 'hello' }, 'text') + vim.api.nvim_buf_set_name(bufnr, '/tmp/preview_test_nobin.txt') + vim.bo[bufnr].modified = false + + local notified = false + local orig = vim.notify + vim.notify = function(msg) + if msg:find('not executable') then + notified = true + end + end + + local provider = { cmd = { 'totally_nonexistent_binary_xyz_preview' } } + local ctx = { + bufnr = bufnr, + file = '/tmp/preview_test_nobin.txt', + root = '/tmp', + ft = 'text', + } + + compiler.compile(bufnr, 'nobin', provider, ctx) + vim.notify = orig + + assert.is_true(notified) + assert.is_nil(compiler._test.active[bufnr]) + helpers.delete_buffer(bufnr) + end) + it('fires PreviewCompileFailed on non-zero exit', function() local bufnr = helpers.create_buffer({ 'hello' }, 'text') vim.api.nvim_buf_set_name(bufnr, '/tmp/preview_test_fail.txt') diff --git a/spec/init_spec.lua b/spec/init_spec.lua index 5c49276..f68438c 100644 --- a/spec/init_spec.lua +++ b/spec/init_spec.lua @@ -108,4 +108,62 @@ describe('preview', function() helpers.delete_buffer(bufnr) end) end) + + describe('unnamed buffer guard', function() + before_each(function() + helpers.reset_config({ typst = true }) + preview = require('preview') + end) + + local function capture_notify(fn) + local msg = nil + local orig = vim.notify + vim.notify = function(m) + msg = m + end + fn() + vim.notify = orig + return msg + end + + it('compile warns on unnamed buffer', function() + local bufnr = helpers.create_buffer({}, 'typst') + local msg = capture_notify(function() + preview.compile(bufnr) + end) + assert.is_not_nil(msg) + assert.is_truthy(msg:find('no file name')) + helpers.delete_buffer(bufnr) + end) + + it('toggle warns on unnamed buffer', function() + local bufnr = helpers.create_buffer({}, 'typst') + local msg = capture_notify(function() + preview.toggle(bufnr) + end) + assert.is_not_nil(msg) + assert.is_truthy(msg:find('no file name')) + helpers.delete_buffer(bufnr) + end) + + it('clean warns on unnamed buffer', function() + local bufnr = helpers.create_buffer({}, 'typst') + local msg = capture_notify(function() + preview.clean(bufnr) + end) + assert.is_not_nil(msg) + assert.is_truthy(msg:find('no file name')) + helpers.delete_buffer(bufnr) + end) + + it('open warns on unnamed buffer', function() + local bufnr = helpers.create_buffer({}, 'typst') + local msg = capture_notify(function() + preview.open(bufnr) + end) + assert.is_not_nil(msg) + assert.is_truthy(msg:find('no file name')) + helpers.delete_buffer(bufnr) + end) + end) end) From 180c6729835fc53c31c4ad288645fc71e622be07 Mon Sep 17 00:00:00 2001 From: Barrett Ruth <62671086+barrettruth@users.noreply.github.com> Date: Wed, 4 Mar 2026 14:02:30 -0500 Subject: [PATCH 13/35] feat(presets): add pdflatex, tectonic, asciidoctor, and quarto presets (#30) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(presets): add pdflatex preset Adds a direct pdflatex preset for users who want single-pass compilation without latexmk orchestration. Uses -file-line-error for parseable diagnostics and reuses the existing parse_latexmk error parser since both emit the same file:line: message format. * feat(presets): add tectonic preset Adds a tectonic preset for the modern Rust-based LaTeX engine, which auto-downloads packages and requires no TeX installation. Reuses parse_latexmk since tectonic emits the same file:line: message diagnostic format. * feat(presets): add asciidoctor preset Adds an asciidoctor preset for AsciiDoc → HTML compilation with SSE live-reload. Includes a parse_asciidoctor error parser handling the "asciidoctor: SEVERITY: file: line N: message" format for both ERROR and WARNING diagnostics. * feat(presets): add quarto preset Adds a quarto preset for .qmd scientific documents rendering to self-contained HTML with SSE live-reload. Uses --embed-resources to avoid a _files directory in the common case. No error_parser since quarto errors are heterogeneous (mixed R/Python/pandoc output). * refactor: apply stylua formatting to new preset code --- doc/preview.nvim.txt | 4 + lua/preview/presets.lua | 92 +++++++++++++++++ spec/presets_spec.lua | 218 ++++++++++++++++++++++++++++++++++++++++ 3 files changed, 314 insertions(+) diff --git a/doc/preview.nvim.txt b/doc/preview.nvim.txt index a3d4981..914f72d 100644 --- a/doc/preview.nvim.txt +++ b/doc/preview.nvim.txt @@ -157,8 +157,12 @@ Import them from `preview.presets`: `presets.typst` typst compile → PDF `presets.latex` latexmk -pdf → PDF (with clean support) + `presets.pdflatex` pdflatex → PDF (single pass, no latexmk) + `presets.tectonic` tectonic → PDF (Rust-based LaTeX engine) `presets.markdown` pandoc → HTML (standalone, embedded) `presets.github` pandoc → HTML (GitHub-styled, `-f gfm` input) + `presets.asciidoctor` asciidoctor → HTML (AsciiDoc with SSE reload) + `presets.quarto` quarto render → HTML (scientific publishing) Enable presets with `preset_name = true`: >lua diff --git a/lua/preview/presets.lua b/lua/preview/presets.lua index fc20f89..8a23766 100644 --- a/lua/preview/presets.lua +++ b/lua/preview/presets.lua @@ -93,6 +93,28 @@ local function parse_pandoc(output) return diagnostics end +---@param output string +---@return preview.Diagnostic[] +local function parse_asciidoctor(output) + local diagnostics = {} + for line in output:gmatch('[^\r\n]+') do + local severity, _, lnum, msg = line:match('^asciidoctor: (%u+): (.+): line (%d+): (.+)$') + if lnum then + local sev = vim.diagnostic.severity.ERROR + if severity == 'WARNING' then + sev = vim.diagnostic.severity.WARN + end + table.insert(diagnostics, { + lnum = tonumber(lnum) - 1, + col = 0, + message = msg, + severity = sev, + }) + end + end + return diagnostics +end + ---@type preview.ProviderConfig M.typst = { ft = 'typst', @@ -137,6 +159,38 @@ M.latex = { open = true, } +---@type preview.ProviderConfig +M.pdflatex = { + ft = 'tex', + cmd = { 'pdflatex' }, + args = function(ctx) + return { '-interaction=nonstopmode', '-file-line-error', '-synctex=1', ctx.file } + end, + output = function(ctx) + return (ctx.file:gsub('%.tex$', '.pdf')) + end, + error_parser = function(output) + return parse_latexmk(output) + end, + open = true, +} + +---@type preview.ProviderConfig +M.tectonic = { + ft = 'tex', + cmd = { 'tectonic' }, + args = function(ctx) + return { ctx.file } + end, + output = function(ctx) + return (ctx.file:gsub('%.tex$', '.pdf')) + end, + error_parser = function(output) + return parse_latexmk(output) + end, + open = true, +} + ---@type preview.ProviderConfig M.markdown = { ft = 'markdown', @@ -187,4 +241,42 @@ M.github = { reload = true, } +---@type preview.ProviderConfig +M.asciidoctor = { + ft = 'asciidoc', + cmd = { 'asciidoctor' }, + args = function(ctx) + return { ctx.file, '-o', ctx.output } + end, + output = function(ctx) + return (ctx.file:gsub('%.adoc$', '.html')) + end, + error_parser = function(output) + return parse_asciidoctor(output) + end, + clean = function(ctx) + return { 'rm', '-f', (ctx.file:gsub('%.adoc$', '.html')) } + end, + open = true, + reload = true, +} + +---@type preview.ProviderConfig +M.quarto = { + ft = 'quarto', + cmd = { 'quarto' }, + args = function(ctx) + return { 'render', ctx.file, '--to', 'html', '--embed-resources' } + end, + output = function(ctx) + return (ctx.file:gsub('%.qmd$', '.html')) + end, + clean = function(ctx) + local base = ctx.file:gsub('%.qmd$', '') + return { 'rm', '-rf', base .. '.html', base .. '_files' } + end, + open = true, + reload = true, +} + return M diff --git a/spec/presets_spec.lua b/spec/presets_spec.lua index 8085a3c..ab030f0 100644 --- a/spec/presets_spec.lua +++ b/spec/presets_spec.lua @@ -157,6 +157,112 @@ describe('presets', function() end) end) + describe('pdflatex', function() + local tex_ctx = { + bufnr = 1, + file = '/tmp/document.tex', + root = '/tmp', + ft = 'tex', + } + + it('has ft', function() + assert.are.equal('tex', presets.pdflatex.ft) + end) + + it('has cmd', function() + assert.are.same({ 'pdflatex' }, presets.pdflatex.cmd) + end) + + it('returns args with flags and file path', function() + local args = presets.pdflatex.args(tex_ctx) + assert.are.same( + { '-interaction=nonstopmode', '-file-line-error', '-synctex=1', '/tmp/document.tex' }, + args + ) + end) + + it('returns pdf output path', function() + assert.are.equal('/tmp/document.pdf', presets.pdflatex.output(tex_ctx)) + end) + + it('has open enabled', function() + assert.is_true(presets.pdflatex.open) + end) + + it('has no clean command', function() + assert.is_nil(presets.pdflatex.clean) + end) + + it('has no reload', function() + assert.is_nil(presets.pdflatex.reload) + end) + + it('parses file-line-error format', function() + local output = './document.tex:10: Undefined control sequence.' + local diagnostics = presets.pdflatex.error_parser(output, tex_ctx) + assert.are.equal(1, #diagnostics) + assert.are.equal(9, diagnostics[1].lnum) + assert.are.equal(0, diagnostics[1].col) + assert.are.equal('Undefined control sequence.', diagnostics[1].message) + assert.are.equal(vim.diagnostic.severity.ERROR, diagnostics[1].severity) + end) + + it('returns empty table for clean output', function() + assert.are.same({}, presets.pdflatex.error_parser('', tex_ctx)) + end) + end) + + describe('tectonic', function() + local tex_ctx = { + bufnr = 1, + file = '/tmp/document.tex', + root = '/tmp', + ft = 'tex', + } + + it('has ft', function() + assert.are.equal('tex', presets.tectonic.ft) + end) + + it('has cmd', function() + assert.are.same({ 'tectonic' }, presets.tectonic.cmd) + end) + + it('returns args with file path', function() + assert.are.same({ '/tmp/document.tex' }, presets.tectonic.args(tex_ctx)) + end) + + it('returns pdf output path', function() + assert.are.equal('/tmp/document.pdf', presets.tectonic.output(tex_ctx)) + end) + + it('has open enabled', function() + assert.is_true(presets.tectonic.open) + end) + + it('has no clean command', function() + assert.is_nil(presets.tectonic.clean) + end) + + it('has no reload', function() + assert.is_nil(presets.tectonic.reload) + end) + + it('parses file-line-error format', function() + local output = './document.tex:5: Missing $ inserted.' + local diagnostics = presets.tectonic.error_parser(output, tex_ctx) + assert.are.equal(1, #diagnostics) + assert.are.equal(4, diagnostics[1].lnum) + assert.are.equal(0, diagnostics[1].col) + assert.are.equal('Missing $ inserted.', diagnostics[1].message) + assert.are.equal(vim.diagnostic.severity.ERROR, diagnostics[1].severity) + end) + + it('returns empty table for clean output', function() + assert.are.same({}, presets.tectonic.error_parser('', tex_ctx)) + end) + end) + describe('markdown', function() local md_ctx = { bufnr = 1, @@ -341,4 +447,116 @@ describe('presets', function() assert.are.same({}, diagnostics) end) end) + + describe('asciidoctor', function() + local adoc_ctx = { + bufnr = 1, + file = '/tmp/document.adoc', + root = '/tmp', + ft = 'asciidoc', + output = '/tmp/document.html', + } + + it('has ft', function() + assert.are.equal('asciidoc', presets.asciidoctor.ft) + end) + + it('has cmd', function() + assert.are.same({ 'asciidoctor' }, presets.asciidoctor.cmd) + end) + + it('returns args with file and output', function() + assert.are.same( + { '/tmp/document.adoc', '-o', '/tmp/document.html' }, + presets.asciidoctor.args(adoc_ctx) + ) + end) + + it('returns html output path', function() + assert.are.equal('/tmp/document.html', presets.asciidoctor.output(adoc_ctx)) + end) + + it('returns clean command', function() + assert.are.same({ 'rm', '-f', '/tmp/document.html' }, presets.asciidoctor.clean(adoc_ctx)) + end) + + it('has open enabled', function() + assert.is_true(presets.asciidoctor.open) + end) + + it('has reload enabled for SSE', function() + assert.is_true(presets.asciidoctor.reload) + end) + + it('parses error messages', function() + local output = + 'asciidoctor: ERROR: document.adoc: line 8: invalid part, must have at least one section' + local diagnostics = presets.asciidoctor.error_parser(output, adoc_ctx) + assert.are.equal(1, #diagnostics) + assert.are.equal(7, diagnostics[1].lnum) + assert.are.equal(0, diagnostics[1].col) + assert.are.equal('invalid part, must have at least one section', diagnostics[1].message) + assert.are.equal(vim.diagnostic.severity.ERROR, diagnostics[1].severity) + end) + + it('parses warning messages', function() + local output = 'asciidoctor: WARNING: document.adoc: line 52: section title out of sequence' + local diagnostics = presets.asciidoctor.error_parser(output, adoc_ctx) + assert.are.equal(1, #diagnostics) + assert.are.equal(51, diagnostics[1].lnum) + assert.are.equal(vim.diagnostic.severity.WARN, diagnostics[1].severity) + end) + + it('returns empty table for clean output', function() + assert.are.same({}, presets.asciidoctor.error_parser('', adoc_ctx)) + end) + end) + + describe('quarto', function() + local qmd_ctx = { + bufnr = 1, + file = '/tmp/document.qmd', + root = '/tmp', + ft = 'quarto', + output = '/tmp/document.html', + } + + it('has ft', function() + assert.are.equal('quarto', presets.quarto.ft) + end) + + it('has cmd', function() + assert.are.same({ 'quarto' }, presets.quarto.cmd) + end) + + it('returns args with render subcommand and html format', function() + assert.are.same( + { 'render', '/tmp/document.qmd', '--to', 'html', '--embed-resources' }, + presets.quarto.args(qmd_ctx) + ) + end) + + it('returns html output path', function() + assert.are.equal('/tmp/document.html', presets.quarto.output(qmd_ctx)) + end) + + it('returns clean command removing html and _files directory', function() + assert.are.same( + { 'rm', '-rf', '/tmp/document.html', '/tmp/document_files' }, + presets.quarto.clean(qmd_ctx) + ) + end) + + it('has open enabled', function() + assert.is_true(presets.quarto.open) + end) + + it('has reload enabled for SSE', function() + assert.is_true(presets.quarto.reload) + end) + + it('has no error_parser', function() + assert.is_nil(presets.quarto.error_parser) + end) + end) end) From 50a21a787d6b2df4832a1201e5af0a5656ccee4d Mon Sep 17 00:00:00 2001 From: Barrett Ruth <62671086+barrettruth@users.noreply.github.com> Date: Wed, 4 Mar 2026 14:23:38 -0500 Subject: [PATCH 14/35] ci: scripts (#31) --- .luarc.json | 8 +++++++- .styluaignore | 1 + flake.nix | 2 ++ scripts/ci.sh | 10 ++++++++++ 4 files changed, 20 insertions(+), 1 deletion(-) create mode 100644 .styluaignore create mode 100755 scripts/ci.sh diff --git a/.luarc.json b/.luarc.json index 23646d3..3f6276a 100644 --- a/.luarc.json +++ b/.luarc.json @@ -2,7 +2,13 @@ "runtime.version": "LuaJIT", "runtime.path": ["lua/?.lua", "lua/?/init.lua"], "diagnostics.globals": ["vim", "jit"], - "workspace.library": ["$VIMRUNTIME/lua", "${3rd}/luv/library"], + "workspace.library": [ + "$VIMRUNTIME/lua", + "${3rd}/luv/library", + "${3rd}/busted/library", + "${3rd}/luassert/library" + ], "workspace.checkThirdParty": false, + "workspace.ignoreDir": [".direnv"], "completion.callSnippet": "Replace" } diff --git a/.styluaignore b/.styluaignore new file mode 100644 index 0000000..9b42106 --- /dev/null +++ b/.styluaignore @@ -0,0 +1 @@ +.direnv/ diff --git a/flake.nix b/flake.nix index 7413113..0243f3e 100644 --- a/flake.nix +++ b/flake.nix @@ -16,6 +16,8 @@ forEachSystem = f: nixpkgs.lib.genAttrs (import systems) (system: f nixpkgs.legacyPackages.${system}); in { + formatter = forEachSystem (pkgs: pkgs.nixfmt-tree); + devShells = forEachSystem (pkgs: { default = pkgs.mkShell { packages = [ diff --git a/scripts/ci.sh b/scripts/ci.sh new file mode 100755 index 0000000..e06bf09 --- /dev/null +++ b/scripts/ci.sh @@ -0,0 +1,10 @@ +#!/bin/sh +set -eu + +nix develop --command stylua --check . +git ls-files '*.lua' | xargs nix develop --command selene --display-style quiet +nix develop --command prettier --check . +nix fmt +git diff --exit-code -- '*.nix' +nix develop --command lua-language-server --check . --checklevel=Warning +nix develop --command busted From dd27374833e12b41137dddc532c6a41ebb86fd7c Mon Sep 17 00:00:00 2001 From: Barrett Ruth <62671086+barrettruth@users.noreply.github.com> Date: Wed, 4 Mar 2026 14:28:52 -0500 Subject: [PATCH 15/35] fix(ci): resolve lua-language-server warnings (#32) Problem: reload_spec.lua called io.open() without nil checks, causing need-check-nil warnings. Adding ${3rd}/busted and ${3rd}/luassert to workspace.library caused lua-language-server 3.7.4 to run diagnostics on its own bundled meta files, surfacing pre-existing cast-local-type bugs in luassert's annotations that are not ours to fix. Solution: use assert(io.open(...)) in reload_spec.lua to satisfy the nil check. Remove busted/luassert library paths from .luarc.json since they only benefit spec/ which is not type-checked in CI. Narrow the lua-language-server check in scripts/ci.sh to lua/ to match CI. --- .luarc.json | 7 +------ flake.nix | 3 ++- scripts/ci.sh | 2 +- spec/reload_spec.lua | 8 ++++---- 4 files changed, 8 insertions(+), 12 deletions(-) diff --git a/.luarc.json b/.luarc.json index 3f6276a..d44eb7c 100644 --- a/.luarc.json +++ b/.luarc.json @@ -2,12 +2,7 @@ "runtime.version": "LuaJIT", "runtime.path": ["lua/?.lua", "lua/?/init.lua"], "diagnostics.globals": ["vim", "jit"], - "workspace.library": [ - "$VIMRUNTIME/lua", - "${3rd}/luv/library", - "${3rd}/busted/library", - "${3rd}/luassert/library" - ], + "workspace.library": ["$VIMRUNTIME/lua", "${3rd}/luv/library"], "workspace.checkThirdParty": false, "workspace.ignoreDir": [".direnv"], "completion.callSnippet": "Replace" diff --git a/flake.nix b/flake.nix index 0243f3e..91a0ab2 100644 --- a/flake.nix +++ b/flake.nix @@ -13,7 +13,8 @@ ... }: let - forEachSystem = f: nixpkgs.lib.genAttrs (import systems) (system: f nixpkgs.legacyPackages.${system}); + forEachSystem = + f: nixpkgs.lib.genAttrs (import systems) (system: f nixpkgs.legacyPackages.${system}); in { formatter = forEachSystem (pkgs: pkgs.nixfmt-tree); diff --git a/scripts/ci.sh b/scripts/ci.sh index e06bf09..98f6ff4 100755 --- a/scripts/ci.sh +++ b/scripts/ci.sh @@ -6,5 +6,5 @@ git ls-files '*.lua' | xargs nix develop --command selene --display-style quiet nix develop --command prettier --check . nix fmt git diff --exit-code -- '*.nix' -nix develop --command lua-language-server --check . --checklevel=Warning +nix develop --command lua-language-server --check lua/ --checklevel=Warning nix develop --command busted diff --git a/spec/reload_spec.lua b/spec/reload_spec.lua index 12b7aac..68b2851 100644 --- a/spec/reload_spec.lua +++ b/spec/reload_spec.lua @@ -13,13 +13,13 @@ describe('reload', function() describe('inject', function() it('injects script before ', function() local path = os.tmpname() - local f = io.open(path, 'w') + local f = assert(io.open(path, 'w')) f:write('

hello

') f:close() reload.inject(path) - local fr = io.open(path, 'r') + local fr = assert(io.open(path, 'r')) local content = fr:read('*a') fr:close() os.remove(path) @@ -33,13 +33,13 @@ describe('reload', function() it('appends script when no ', function() local path = os.tmpname() - local f = io.open(path, 'w') + local f = assert(io.open(path, 'w')) f:write('

hello

') f:close() reload.inject(path) - local fr = io.open(path, 'r') + local fr = assert(io.open(path, 'r')) local content = fr:read('*a') fr:close() os.remove(path) From ec00648f7a68c0e4e17df47a24eddd96596650c6 Mon Sep 17 00:00:00 2001 From: Barrett Ruth <62671086+barrettruth@users.noreply.github.com> Date: Wed, 4 Mar 2026 14:31:17 -0500 Subject: [PATCH 16/35] fix(ci): pass --configpath to lua-language-server (#33) Problem: lua-language-server --check lua/ treats lua/ as its workspace root and fails to find .luarc.json in the project root, so diagnostics globals (vim, jit) are not loaded and every vim.* reference is flagged as undefined-global. Solution: pass --configpath .luarc.json explicitly, matching what the GitHub CI action already does. --- scripts/ci.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/ci.sh b/scripts/ci.sh index 98f6ff4..849be6a 100755 --- a/scripts/ci.sh +++ b/scripts/ci.sh @@ -6,5 +6,5 @@ git ls-files '*.lua' | xargs nix develop --command selene --display-style quiet nix develop --command prettier --check . nix fmt git diff --exit-code -- '*.nix' -nix develop --command lua-language-server --check lua/ --checklevel=Warning +nix develop --command lua-language-server --check lua/ --configpath .luarc.json --checklevel=Warning nix develop --command busted From d4e7d8c2fddfd633b6cc93e915361f2d61bfc3b9 Mon Sep 17 00:00:00 2001 From: Barrett Ruth <62671086+barrettruth@users.noreply.github.com> Date: Wed, 4 Mar 2026 14:33:25 -0500 Subject: [PATCH 17/35] fix(ci): use absolute path for lua-language-server --configpath (#34) Problem: --configpath is resolved relative to the workspace root passed to --check (lua/), not CWD. So .luarc.json was looked up at lua/.luarc.json and not found, leaving vim and jit as undefined globals. Solution: expand to an absolute path with $(pwd) at shell invocation time, matching what the GitHub CI action already does. --- scripts/ci.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/ci.sh b/scripts/ci.sh index 849be6a..37b1267 100755 --- a/scripts/ci.sh +++ b/scripts/ci.sh @@ -6,5 +6,5 @@ git ls-files '*.lua' | xargs nix develop --command selene --display-style quiet nix develop --command prettier --check . nix fmt git diff --exit-code -- '*.nix' -nix develop --command lua-language-server --check lua/ --configpath .luarc.json --checklevel=Warning +nix develop --command lua-language-server --check lua/ --configpath "$(pwd)/.luarc.json" --checklevel=Warning nix develop --command busted From 68e2e8223278e27cddfe9c6f0754e7594546e696 Mon Sep 17 00:00:00 2001 From: Barrett Ruth <62671086+barrettruth@users.noreply.github.com> Date: Wed, 4 Mar 2026 14:57:36 -0500 Subject: [PATCH 18/35] fix(presets): add --failure-level ERROR to asciidoctor, add clean to typst/pdflatex/tectonic, skip auto-open on one-shot compile (#35) Problem: asciidoctor exits 0 on errors so error_parser never ran. typst, pdflatex, and tectonic had no clean subcommand. auto-open fired on :Preview compile, surprising users who just want a build. Solution: pass --failure-level ERROR in asciidoctor args. Add clean commands to typst (rm pdf), pdflatex (rm pdf/aux/log/synctex.gz), and tectonic (rm pdf). Gate auto-open on not opts.oneshot so it only fires during toggle/watch mode. --- lua/preview/compiler.lua | 2 ++ lua/preview/presets.lua | 12 +++++++++++- spec/presets_spec.lua | 22 +++++++++++++++++----- 3 files changed, 30 insertions(+), 6 deletions(-) diff --git a/lua/preview/compiler.lua b/lua/preview/compiler.lua index 74d6070..38a048e 100644 --- a/lua/preview/compiler.lua +++ b/lua/preview/compiler.lua @@ -162,6 +162,7 @@ function M.compile(bufnr, name, provider, ctx, opts) if provider.open + and not opts.oneshot and not opened[bufnr] and output_file ~= '' and vim.uv.fs_stat(output_file) @@ -240,6 +241,7 @@ function M.compile(bufnr, name, provider, ctx, opts) end if provider.open + and not opts.oneshot and not opened[bufnr] and output_file ~= '' and vim.uv.fs_stat(output_file) diff --git a/lua/preview/presets.lua b/lua/preview/presets.lua index 8a23766..f189e98 100644 --- a/lua/preview/presets.lua +++ b/lua/preview/presets.lua @@ -128,6 +128,9 @@ M.typst = { error_parser = function(output) return parse_typst(output) end, + clean = function(ctx) + return { 'rm', '-f', (ctx.file:gsub('%.typ$', '.pdf')) } + end, open = true, reload = function(ctx) return { 'typst', 'watch', ctx.file } @@ -172,6 +175,10 @@ M.pdflatex = { error_parser = function(output) return parse_latexmk(output) end, + clean = function(ctx) + local base = ctx.file:gsub('%.tex$', '') + return { 'rm', '-f', base .. '.pdf', base .. '.aux', base .. '.log', base .. '.synctex.gz' } + end, open = true, } @@ -188,6 +195,9 @@ M.tectonic = { error_parser = function(output) return parse_latexmk(output) end, + clean = function(ctx) + return { 'rm', '-f', (ctx.file:gsub('%.tex$', '.pdf')) } + end, open = true, } @@ -246,7 +256,7 @@ M.asciidoctor = { ft = 'asciidoc', cmd = { 'asciidoctor' }, args = function(ctx) - return { ctx.file, '-o', ctx.output } + return { '--failure-level', 'ERROR', ctx.file, '-o', ctx.output } end, output = function(ctx) return (ctx.file:gsub('%.adoc$', '.html')) diff --git a/spec/presets_spec.lua b/spec/presets_spec.lua index ab030f0..2a63d18 100644 --- a/spec/presets_spec.lua +++ b/spec/presets_spec.lua @@ -33,6 +33,10 @@ describe('presets', function() assert.are.equal('/tmp/document.pdf', output) end) + it('returns clean command', function() + assert.are.same({ 'rm', '-f', '/tmp/document.pdf' }, presets.typst.clean(ctx)) + end) + it('has open enabled', function() assert.is_true(presets.typst.open) end) @@ -189,8 +193,16 @@ describe('presets', function() assert.is_true(presets.pdflatex.open) end) - it('has no clean command', function() - assert.is_nil(presets.pdflatex.clean) + it('returns clean command removing pdf and aux files', function() + local clean = presets.pdflatex.clean(tex_ctx) + assert.are.same({ + 'rm', + '-f', + '/tmp/document.pdf', + '/tmp/document.aux', + '/tmp/document.log', + '/tmp/document.synctex.gz', + }, clean) end) it('has no reload', function() @@ -240,8 +252,8 @@ describe('presets', function() assert.is_true(presets.tectonic.open) end) - it('has no clean command', function() - assert.is_nil(presets.tectonic.clean) + it('returns clean command removing pdf', function() + assert.are.same({ 'rm', '-f', '/tmp/document.pdf' }, presets.tectonic.clean(tex_ctx)) end) it('has no reload', function() @@ -467,7 +479,7 @@ describe('presets', function() it('returns args with file and output', function() assert.are.same( - { '/tmp/document.adoc', '-o', '/tmp/document.html' }, + { '--failure-level', 'ERROR', '/tmp/document.adoc', '-o', '/tmp/document.html' }, presets.asciidoctor.args(adoc_ctx) ) end) From 7a11f39341cd0354cf68717c6094875c06ef76c0 Mon Sep 17 00:00:00 2001 From: Barrett Ruth <62671086+barrettruth@users.noreply.github.com> Date: Wed, 4 Mar 2026 15:09:07 -0500 Subject: [PATCH 19/35] docs: pre-release polish (#36) * docs: pre-release polish Update README preset list to include pdflatex, tectonic, asciidoctor, and quarto. Fix custom provider FAQ example to use a non-preset key. Clarify open field fires on toggle/watch mode only, not :Preview compile. Expand intro to mention AsciiDoc and Quarto alongside existing tools. * docs: update slogan to universal document previewer * ci: format --- README.md | 17 +++++++++-------- doc/preview.nvim.txt | 12 +++++++----- 2 files changed, 16 insertions(+), 13 deletions(-) diff --git a/README.md b/README.md index 1d4446b..c7cc7f3 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # preview.nvim -**Async document compilation for Neovim** +**Universal document previewer for Neovim** An extensible framework for compiling documents (LaTeX, Typst, Markdown, etc.) asynchronously with error diagnostics. @@ -8,7 +8,8 @@ asynchronously with error diagnostics. ## Features - Async compilation via `vim.system()` -- Built-in presets for Typst, LaTeX, Markdown, and GitHub-flavored Markdown +- Built-in presets for Typst, LaTeX (latexmk, pdflatex, tectonic), Markdown, + GitHub-flavored Markdown, AsciiDoc, and Quarto - Compiler errors as native `vim.diagnostic` - User events for extensibility (`PreviewCompileStarted`, `PreviewCompileSuccess`, `PreviewCompileFailed`) @@ -38,13 +39,13 @@ luarocks install preview.nvim ```lua require('preview').setup({ - typst = { - cmd = { 'typst', 'compile' }, + rst = { + cmd = { 'rst2html' }, args = function(ctx) - return { ctx.file } + return { ctx.file, ctx.output } end, output = function(ctx) - return ctx.file:gsub('%.typ$', '.pdf') + return ctx.file:gsub('%.rst$', '.html') end, }, }) @@ -61,8 +62,8 @@ require('preview').setup({ **Q: How do I automatically open the output file?** Set `open = true` on your provider (all built-in presets have this enabled) to -open the output with `vim.ui.open()` after the first successful compilation. For -a specific application, pass a command table: +open the output with `vim.ui.open()` after the first successful compilation in +toggle/watch mode. For a specific application, pass a command table: ```lua require('preview').setup({ diff --git a/doc/preview.nvim.txt b/doc/preview.nvim.txt index 914f72d..6cba09a 100644 --- a/doc/preview.nvim.txt +++ b/doc/preview.nvim.txt @@ -10,8 +10,9 @@ preview.nvim is an extensible framework for compiling documents asynchronously in Neovim. It provides a unified interface for any compilation workflow — LaTeX, Typst, Markdown, or anything else with a CLI compiler. -The plugin ships with opt-in presets for common tools (Typst, LaTeX, Pandoc) -and supports fully custom providers. See |preview.nvim-presets|. +The plugin ships with opt-in presets for common tools (Typst, LaTeX, Pandoc, +AsciiDoc, Quarto) and supports fully custom providers. +See |preview.nvim-presets|. ============================================================================== REQUIREMENTS *preview.nvim-requirements* @@ -89,9 +90,10 @@ Provider fields:~ |preview.Context|. `open` boolean|string[] Open the output file after the first - successful compilation. `true` uses - |vim.ui.open()|. A string[] is run as - a command with the output path appended. + successful compilation in toggle/watch + mode. `true` uses |vim.ui.open()|. A + string[] is run as a command with the + output path appended. `reload` boolean|string[]|function Reload the output after recompilation. From e8f93fb47ee85c6c571ec8f98492c1bf590fe223 Mon Sep 17 00:00:00 2001 From: Barrett Ruth Date: Wed, 4 Mar 2026 15:09:45 -0500 Subject: [PATCH 20/35] doc: readme typo --- README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index c7cc7f3..031d096 100644 --- a/README.md +++ b/README.md @@ -2,8 +2,8 @@ **Universal document previewer for Neovim** -An extensible framework for compiling documents (LaTeX, Typst, Markdown, etc.) -asynchronously with error diagnostics. +An extensible framework for compiling and previewing documents (LaTeX, Typst, +Markdown, etc.) asynchronously with error diagnostics. ## Features From 934ef037289a335b696d6940f172be3436febe1d Mon Sep 17 00:00:00 2001 From: Barrett Ruth Date: Wed, 4 Mar 2026 15:10:40 -0500 Subject: [PATCH 21/35] doc: cleanup readme --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 031d096..58e1353 100644 --- a/README.md +++ b/README.md @@ -3,7 +3,7 @@ **Universal document previewer for Neovim** An extensible framework for compiling and previewing documents (LaTeX, Typst, -Markdown, etc.) asynchronously with error diagnostics. +Markdown, etc.). ## Features From df0765a27f59ab4d8256c02e91708eb4bf828cf6 Mon Sep 17 00:00:00 2001 From: Barrett Ruth Date: Wed, 4 Mar 2026 15:11:44 -0500 Subject: [PATCH 22/35] doc: cleanup --- README.html | 963 ++++++++++++++++++++++++++++++++++++++++++++++++++++ README.md | 4 +- 2 files changed, 965 insertions(+), 2 deletions(-) create mode 100644 README.html diff --git a/README.html b/README.html new file mode 100644 index 0000000..b6600fc --- /dev/null +++ b/README.html @@ -0,0 +1,963 @@ + + + + + + + README + + + + +

preview.nvim

+

Universal document previewer for Neovim

+

An extensible framework for compiling and previewing any +documents (LaTeX, Typst, Markdown, etc.)—diagnostics included.

+

Features

+
    +
  • Async compilation via vim.system()
  • +
  • Built-in presets for Typst, LaTeX (latexmk, pdflatex, tectonic), +Markdown, GitHub-flavored Markdown, AsciiDoc, and Quarto
  • +
  • Compiler errors as native vim.diagnostic
  • +
  • User events for extensibility (PreviewCompileStarted, +PreviewCompileSuccess, +PreviewCompileFailed)
  • +
+

Requirements

+
    +
  • Neovim 0.11+
  • +
+

Installation

+

Install with your package manager of choice or via luarocks:

+
luarocks install preview.nvim
+

Documentation

+
:help preview.nvim
+

FAQ

+

Q: How do I define a custom provider?

+
require('preview').setup({
+  rst = {
+    cmd = { 'rst2html' },
+    args = function(ctx)
+      return { ctx.file, ctx.output }
+    end,
+    output = function(ctx)
+      return ctx.file:gsub('%.rst$', '.html')
+    end,
+  },
+})
+

Q: How do I override a preset?

+
require('preview').setup({
+  typst = { env = { TYPST_FONT_PATHS = '/usr/share/fonts' } },
+})
+

Q: How do I automatically open the output file?

+

Set open = true on your provider (all built-in presets +have this enabled) to open the output with vim.ui.open() +after the first successful compilation in toggle/watch mode. For a +specific application, pass a command table:

+
require('preview').setup({
+  typst = { open = { 'sioyek', '--new-instance' } },
+})
+ + + diff --git a/README.md b/README.md index 58e1353..286e1ac 100644 --- a/README.md +++ b/README.md @@ -2,8 +2,8 @@ **Universal document previewer for Neovim** -An extensible framework for compiling and previewing documents (LaTeX, Typst, -Markdown, etc.). +An extensible framework for compiling and previewing *any* documents (LaTeX, Typst, +Markdown, etc.)—diagnostics included. ## Features From 239f8a47692f45c2ff7b98639057da641562482e Mon Sep 17 00:00:00 2001 From: Barrett Ruth <62671086+barrettruth@users.noreply.github.com> Date: Wed, 4 Mar 2026 15:48:30 -0500 Subject: [PATCH 23/35] fix(compiler): defer open until successful compile, close viewer on :bd (#38) * fix(compiler): defer open until successful compile, close viewer on :bd Problem: For long-running providers (e.g. `typst watch`), the viewer was opened immediately on toggle start by checking if the output file existed on disk. A stale PDF from a prior session satisfied that check, so a failed compile still opened the viewer. Additionally, viewer processes spawned via a table `open` command were untracked, so `:bd` killed the compiler but left the viewer running. Solution: Replace the immediate open with a `vim.uv.new_fs_event` directory watcher that fires only when the output file's `mtime` advances past its pre-compile value, proving the current session wrote it. Add `viewer_procs` and `open_watchers` tables with `close_viewer` and `stop_open_watcher` helpers; all `BufUnload` paths and `stop_all` now tear down both. Extract `do_open` to deduplicate the open branching logic across three call sites. * docs: document viewer auto-close behaviour and limitations in `open` field * ci: format --- doc/preview.nvim.txt | 7 ++- lua/preview/compiler.lua | 115 +++++++++++++++++++++++++++++---------- 2 files changed, 93 insertions(+), 29 deletions(-) diff --git a/doc/preview.nvim.txt b/doc/preview.nvim.txt index 6cba09a..e2747d6 100644 --- a/doc/preview.nvim.txt +++ b/doc/preview.nvim.txt @@ -93,7 +93,12 @@ Provider fields:~ successful compilation in toggle/watch mode. `true` uses |vim.ui.open()|. A string[] is run as a command with the - output path appended. + output path appended. When a string[] + is used the viewer process is tracked + and sent SIGTERM when the buffer is + deleted. `true` and single-instance + apps (e.g. Chrome) do not support + auto-close. `reload` boolean|string[]|function Reload the output after recompilation. diff --git a/lua/preview/compiler.lua b/lua/preview/compiler.lua index 38a048e..c5a81c2 100644 --- a/lua/preview/compiler.lua +++ b/lua/preview/compiler.lua @@ -15,10 +15,49 @@ local opened = {} ---@type table local last_output = {} +---@type table +local viewer_procs = {} + +---@type table +local open_watchers = {} + local debounce_timers = {} local DEBOUNCE_MS = 500 +---@param bufnr integer +local function stop_open_watcher(bufnr) + local w = open_watchers[bufnr] + if w then + w:stop() + w:close() + open_watchers[bufnr] = nil + end +end + +---@param bufnr integer +local function close_viewer(bufnr) + local obj = viewer_procs[bufnr] + if obj then + local kill = obj.kill + kill(obj, 'sigterm') + viewer_procs[bufnr] = nil + end +end + +---@param bufnr integer +---@param output_file string +---@param open_config boolean|string[] +local function do_open(bufnr, output_file, open_config) + if open_config == true then + vim.ui.open(output_file) + elseif type(open_config) == 'table' then + local open_cmd = vim.list_extend({}, open_config) + table.insert(open_cmd, output_file) + viewer_procs[bufnr] = vim.system(open_cmd) + end +end + ---@param val string[]|fun(ctx: preview.Context): string[] ---@param ctx preview.Context ---@return string[] @@ -160,21 +199,40 @@ function M.compile(bufnr, name, provider, ctx, opts) end) ) - if - provider.open - and not opts.oneshot - and not opened[bufnr] - and output_file ~= '' - and vim.uv.fs_stat(output_file) - then - if provider.open == true then - vim.ui.open(output_file) - elseif type(provider.open) == 'table' then - local open_cmd = vim.list_extend({}, provider.open) - table.insert(open_cmd, output_file) - vim.system(open_cmd) + if provider.open and not opts.oneshot and not opened[bufnr] and output_file ~= '' then + local pre_stat = vim.uv.fs_stat(output_file) + local pre_mtime = pre_stat and pre_stat.mtime.sec or 0 + local out_dir = vim.fn.fnamemodify(output_file, ':h') + local out_name = vim.fn.fnamemodify(output_file, ':t') + stop_open_watcher(bufnr) + local watcher = vim.uv.new_fs_event() + if watcher then + open_watchers[bufnr] = watcher + watcher: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 opened[bufnr] then + stop_open_watcher(bufnr) + return + end + if not vim.api.nvim_buf_is_valid(bufnr) then + stop_open_watcher(bufnr) + return + end + local new_stat = vim.uv.fs_stat(output_file) + if not (new_stat and new_stat.mtime.sec > pre_mtime) then + return + end + stop_open_watcher(bufnr) + do_open(bufnr, output_file, provider.open) + opened[bufnr] = true + end) + ) end - opened[bufnr] = true end active[bufnr] = { obj = obj, provider = name, output_file = output_file, is_reload = true } @@ -184,6 +242,8 @@ function M.compile(bufnr, name, provider, ctx, opts) once = true, callback = function() M.stop(bufnr) + stop_open_watcher(bufnr) + close_viewer(bufnr) last_output[bufnr] = nil end, }) @@ -246,13 +306,7 @@ function M.compile(bufnr, name, provider, ctx, opts) and output_file ~= '' and vim.uv.fs_stat(output_file) then - if provider.open == true then - vim.ui.open(output_file) - elseif type(provider.open) == 'table' then - local open_cmd = vim.list_extend({}, provider.open) - table.insert(open_cmd, output_file) - vim.system(open_cmd) - end + do_open(bufnr, output_file, provider.open) opened[bufnr] = true end else @@ -299,6 +353,7 @@ function M.compile(bufnr, name, provider, ctx, opts) once = true, callback = function() M.stop(bufnr) + close_viewer(bufnr) last_output[bufnr] = nil end, }) @@ -339,6 +394,12 @@ function M.stop_all() for bufnr, _ in pairs(watching) do M.unwatch(bufnr) end + for bufnr, _ in pairs(open_watchers) do + stop_open_watcher(bufnr) + end + for bufnr, _ in pairs(viewer_procs) do + close_viewer(bufnr) + end require('preview.reload').stop() end @@ -394,6 +455,8 @@ function M.toggle(bufnr, name, provider, ctx_builder) once = true, callback = function() M.unwatch(bufnr) + stop_open_watcher(bufnr) + close_viewer(bufnr) opened[bufnr] = nil end, }) @@ -471,13 +534,7 @@ function M.open(bufnr, open_config) log.dbg('output file no longer exists for buffer %d: %s', bufnr, output) return false end - if type(open_config) == 'table' then - local open_cmd = vim.list_extend({}, open_config) - table.insert(open_cmd, output) - vim.system(open_cmd) - else - vim.ui.open(output) - end + do_open(bufnr, output, open_config) return true end @@ -502,6 +559,8 @@ M._test = { opened = opened, last_output = last_output, debounce_timers = debounce_timers, + viewer_procs = viewer_procs, + open_watchers = open_watchers, } return M From c8e3a88434bfa7800d9d48cea39fdbc86c3da94d Mon Sep 17 00:00:00 2001 From: Barrett Ruth <62671086+barrettruth@users.noreply.github.com> Date: Wed, 4 Mar 2026 16:33:58 -0500 Subject: [PATCH 24/35] fix: stream stderr for long-running providers, clear errors on success (#40) Problem: Long-running providers (e.g. `typst watch`) never exit on compile error, so the exit callback never fired and diagnostics/quickfix were never populated. The `typst watch` `reload` command also lacked `--diagnostic-format short`, producing unparseable verbose output. Solution: Add a `stderr` streaming callback to the long-running `vim.system` call that accumulates chunks and re-parses on each new chunk, populating diagnostics or quickfix in real time. When the fs_event fires (successful compile), clear `stderr_acc` and the reported errors. Add `--diagnostic-format short` to the typst `reload` command to match the one-shot `args` format. --- lua/preview/compiler.lua | 43 ++++++++++++++++++++++++++++++++++++++++ lua/preview/presets.lua | 2 +- 2 files changed, 44 insertions(+), 1 deletion(-) diff --git a/lua/preview/compiler.lua b/lua/preview/compiler.lua index c5a81c2..3f897ad 100644 --- a/lua/preview/compiler.lua +++ b/lua/preview/compiler.lua @@ -143,12 +143,45 @@ function M.compile(bufnr, name, provider, ctx, opts) table.concat(reload_cmd, ' ') ) + local stderr_acc = {} local obj obj = vim.system( reload_cmd, { cwd = cwd, env = provider.env, + stderr = vim.schedule_wrap(function(err, data) + if not data or not vim.api.nvim_buf_is_valid(bufnr) then + return + end + stderr_acc[#stderr_acc + 1] = data + local errors_mode = provider.errors + if errors_mode == nil then + errors_mode = 'diagnostic' + end + if provider.error_parser and errors_mode then + local output = table.concat(stderr_acc) + if errors_mode == 'diagnostic' then + 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 + local items = {} + for _, d in ipairs(diags) do + table.insert(items, { + bufnr = bufnr, + lnum = d.lnum + 1, + col = d.col + 1, + text = d.message, + type = d.severity == vim.diagnostic.severity.WARN and 'W' or 'E', + }) + end + vim.fn.setqflist(items, 'r') + vim.cmd('copen') + end + end + end + end), }, vim.schedule_wrap(function(result) if active[bufnr] and active[bufnr].obj == obj then @@ -228,6 +261,16 @@ function M.compile(bufnr, name, provider, ctx, opts) return end stop_open_watcher(bufnr) + stderr_acc = {} + local errors_mode = provider.errors + if errors_mode == nil then + errors_mode = 'diagnostic' + end + if errors_mode == 'diagnostic' then + diagnostic.clear(bufnr) + elseif errors_mode == 'quickfix' then + vim.fn.setqflist({}, 'r') + end do_open(bufnr, output_file, provider.open) opened[bufnr] = true end) diff --git a/lua/preview/presets.lua b/lua/preview/presets.lua index f189e98..3591a21 100644 --- a/lua/preview/presets.lua +++ b/lua/preview/presets.lua @@ -133,7 +133,7 @@ M.typst = { end, open = true, reload = function(ctx) - return { 'typst', 'watch', ctx.file } + return { 'typst', 'watch', '--diagnostic-format', 'short', ctx.file } end, } From 3e6ba580e4e3c8dc544c0994659757ed12066c3f Mon Sep 17 00:00:00 2001 From: Barrett Ruth <62671086+barrettruth@users.noreply.github.com> Date: Wed, 4 Mar 2026 17:23:06 -0500 Subject: [PATCH 25/35] fix: quickfix support for long-running providers (#41) * fix(compiler): open quickfix in background, retain focus on source buffer * fix(compiler): use cwindow and win_gotoid for quickfix focus management * fix: unused var warning and update typst reload test for short format * fix: remove testing files --- README.html | 963 --------------------------------------- README.md | 4 +- lua/preview/compiler.lua | 16 +- spec/presets_spec.lua | 4 +- 4 files changed, 17 insertions(+), 970 deletions(-) delete mode 100644 README.html diff --git a/README.html b/README.html deleted file mode 100644 index b6600fc..0000000 --- a/README.html +++ /dev/null @@ -1,963 +0,0 @@ - - - - - - - README - - - - -

preview.nvim

-

Universal document previewer for Neovim

-

An extensible framework for compiling and previewing any -documents (LaTeX, Typst, Markdown, etc.)—diagnostics included.

-

Features

-
    -
  • Async compilation via vim.system()
  • -
  • Built-in presets for Typst, LaTeX (latexmk, pdflatex, tectonic), -Markdown, GitHub-flavored Markdown, AsciiDoc, and Quarto
  • -
  • Compiler errors as native vim.diagnostic
  • -
  • User events for extensibility (PreviewCompileStarted, -PreviewCompileSuccess, -PreviewCompileFailed)
  • -
-

Requirements

-
    -
  • Neovim 0.11+
  • -
-

Installation

-

Install with your package manager of choice or via luarocks:

-
luarocks install preview.nvim
-

Documentation

-
:help preview.nvim
-

FAQ

-

Q: How do I define a custom provider?

-
require('preview').setup({
-  rst = {
-    cmd = { 'rst2html' },
-    args = function(ctx)
-      return { ctx.file, ctx.output }
-    end,
-    output = function(ctx)
-      return ctx.file:gsub('%.rst$', '.html')
-    end,
-  },
-})
-

Q: How do I override a preset?

-
require('preview').setup({
-  typst = { env = { TYPST_FONT_PATHS = '/usr/share/fonts' } },
-})
-

Q: How do I automatically open the output file?

-

Set open = true on your provider (all built-in presets -have this enabled) to open the output with vim.ui.open() -after the first successful compilation in toggle/watch mode. For a -specific application, pass a command table:

-
require('preview').setup({
-  typst = { open = { 'sioyek', '--new-instance' } },
-})
- - - diff --git a/README.md b/README.md index 286e1ac..18bc192 100644 --- a/README.md +++ b/README.md @@ -2,8 +2,8 @@ **Universal document previewer for Neovim** -An extensible framework for compiling and previewing *any* documents (LaTeX, Typst, -Markdown, etc.)—diagnostics included. +An extensible framework for compiling and previewing _any_ documents (LaTeX, +Typst, Markdown, etc.)—diagnostics included. ## Features diff --git a/lua/preview/compiler.lua b/lua/preview/compiler.lua index 3f897ad..a3b9c47 100644 --- a/lua/preview/compiler.lua +++ b/lua/preview/compiler.lua @@ -150,7 +150,7 @@ function M.compile(bufnr, name, provider, ctx, opts) { cwd = cwd, env = provider.env, - stderr = vim.schedule_wrap(function(err, data) + stderr = vim.schedule_wrap(function(_err, data) if not data or not vim.api.nvim_buf_is_valid(bufnr) then return end @@ -177,7 +177,9 @@ function M.compile(bufnr, name, provider, ctx, opts) }) end vim.fn.setqflist(items, 'r') - vim.cmd('copen') + local win = vim.fn.win_getid() + vim.cmd.cwindow() + vim.fn.win_gotoid(win) end end end @@ -215,7 +217,9 @@ function M.compile(bufnr, name, provider, ctx, opts) }) end vim.fn.setqflist(items, 'r') - vim.cmd('copen') + local win = vim.fn.win_getid() + vim.cmd.cwindow() + vim.fn.win_gotoid(win) end end end @@ -270,6 +274,7 @@ function M.compile(bufnr, name, provider, ctx, opts) diagnostic.clear(bufnr) elseif errors_mode == 'quickfix' then vim.fn.setqflist({}, 'r') + vim.cmd.cwindow() end do_open(bufnr, output_file, provider.open) opened[bufnr] = true @@ -331,6 +336,7 @@ function M.compile(bufnr, name, provider, ctx, opts) diagnostic.clear(bufnr) elseif errors_mode == 'quickfix' then vim.fn.setqflist({}, 'r') + vim.cmd.cwindow() end vim.api.nvim_exec_autocmds('User', { pattern = 'PreviewCompileSuccess', @@ -372,7 +378,9 @@ function M.compile(bufnr, name, provider, ctx, opts) }) end vim.fn.setqflist(items, 'r') - vim.cmd('copen') + local win = vim.fn.win_getid() + vim.cmd.cwindow() + vim.fn.win_gotoid(win) end end end diff --git a/spec/presets_spec.lua b/spec/presets_spec.lua index 2a63d18..2160dfa 100644 --- a/spec/presets_spec.lua +++ b/spec/presets_spec.lua @@ -50,7 +50,9 @@ describe('presets', function() assert.is_table(result) assert.are.equal('typst', result[1]) assert.are.equal('watch', result[2]) - assert.are.equal(ctx.file, result[3]) + assert.are.equal('--diagnostic-format', result[3]) + assert.are.equal('short', result[4]) + assert.are.equal(ctx.file, result[5]) end) it('parses errors from stderr', function() From cf8fd02e6d844f203ef958e24c75656ca4ba20d3 Mon Sep 17 00:00:00 2001 From: Barrett Ruth <62671086+barrettruth@users.noreply.github.com> Date: Wed, 4 Mar 2026 17:23:57 -0500 Subject: [PATCH 26/35] Add video demonstration to README Added a video demonstration to the README. --- README.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/README.md b/README.md index 18bc192..8e0f56f 100644 --- a/README.md +++ b/README.md @@ -5,6 +5,8 @@ An extensible framework for compiling and previewing _any_ documents (LaTeX, Typst, Markdown, etc.)—diagnostics included. + + ## Features - Async compilation via `vim.system()` From 8107f8c0acd616d3d315c2d25e1b1179de554f0a Mon Sep 17 00:00:00 2001 From: Barrett Ruth Date: Wed, 4 Mar 2026 17:28:07 -0500 Subject: [PATCH 27/35] doc: improve error phrasing, remove redundant feautre --- README.md | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/README.md b/README.md index 8e0f56f..aa9dd91 100644 --- a/README.md +++ b/README.md @@ -12,9 +12,7 @@ Typst, Markdown, etc.)—diagnostics included. - Async compilation via `vim.system()` - Built-in presets for Typst, LaTeX (latexmk, pdflatex, tectonic), Markdown, GitHub-flavored Markdown, AsciiDoc, and Quarto -- Compiler errors as native `vim.diagnostic` -- User events for extensibility (`PreviewCompileStarted`, - `PreviewCompileSuccess`, `PreviewCompileFailed`) +- Compiler errors via `vim.diagnostic` or quickfix ## Requirements From bb9ca987e10883ecb89a84feac5f1e32ba5a7bd8 Mon Sep 17 00:00:00 2001 From: Barrett Ruth <62671086+barrettruth@users.noreply.github.com> Date: Wed, 4 Mar 2026 19:12:33 -0500 Subject: [PATCH 28/35] Add note about previewer auto-close feature --- README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/README.md b/README.md index aa9dd91..4160267 100644 --- a/README.md +++ b/README.md @@ -13,6 +13,7 @@ Typst, Markdown, etc.)—diagnostics included. - Built-in presets for Typst, LaTeX (latexmk, pdflatex, tectonic), Markdown, GitHub-flavored Markdown, AsciiDoc, and Quarto - Compiler errors via `vim.diagnostic` or quickfix +- Previewer auto-close on buffer deletion ## Requirements From f1aed82f4251215b0927b607e30d2358ac3d4231 Mon Sep 17 00:00:00 2001 From: Barrett Ruth <62671086+barrettruth@users.noreply.github.com> Date: Wed, 4 Mar 2026 19:30:56 -0500 Subject: [PATCH 29/35] feat: add `detach` provider field and `vim.g.preview` config support (#42) Problem: viewer processes launched via a string[] `open` command were always killed on buffer deletion with no way to opt out. Configuring the plugin also required an explicit `setup()` call in a `config` hook, preventing config from being declared before the plugin loads. Solution: add a `detach` boolean to `ProviderConfig` that skips SIGTERM on buffer unload. Auto-call `setup()` from `vim.g.preview` at module load time, enabling config via lazy.nvim's `init` hook. Update vimdoc and README accordingly. --- README.md | 14 ++++++++++++-- doc/preview.nvim.txt | 15 +++++++++++++-- lua/preview/compiler.lua | 12 +++++++++--- lua/preview/init.lua | 6 ++++++ 4 files changed, 40 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index 4160267..3bbb203 100644 --- a/README.md +++ b/README.md @@ -21,8 +21,18 @@ Typst, Markdown, etc.)—diagnostics included. ## Installation -Install with your package manager of choice or via -[luarocks](https://luarocks.org/modules/barrettruth/preview.nvim): +With lazy.nvim: + +```lua +{ + 'barrettruth/preview.nvim', + init = function() + vim.g.preview = { typst = true, latex = true } + end, +} +``` + +Or via [luarocks](https://luarocks.org/modules/barrettruth/preview.nvim): ``` luarocks install preview.nvim diff --git a/doc/preview.nvim.txt b/doc/preview.nvim.txt index e2747d6..c64db62 100644 --- a/doc/preview.nvim.txt +++ b/doc/preview.nvim.txt @@ -23,12 +23,17 @@ REQUIREMENTS *preview.nvim-requirements ============================================================================== SETUP *preview.nvim-setup* -Load preview.nvim with your package manager. For example, with lazy.nvim: >lua +With lazy.nvim, set |vim.g.preview| in `init` so configuration is applied +before the plugin loads: >lua { 'barrettruth/preview.nvim', + init = function() + vim.g.preview = { typst = true, latex = true } + end, } < -Call |preview.setup()| to configure providers before use. +Alternatively, call |preview.setup()| directly in a `config` function or +anywhere the plugin is already loaded. ============================================================================== CONFIGURATION *preview.nvim-configuration* @@ -108,6 +113,12 @@ Provider fields:~ |preview.Context| and returns a string[]. + `detach` boolean When `true`, the viewer process opened + via a string[] `open` command is not + sent SIGTERM when the buffer is deleted. + Has no effect when `open` is `true`. + Default: `false`. + *preview.Context* Context fields:~ diff --git a/lua/preview/compiler.lua b/lua/preview/compiler.lua index a3b9c47..a193ad2 100644 --- a/lua/preview/compiler.lua +++ b/lua/preview/compiler.lua @@ -291,7 +291,9 @@ function M.compile(bufnr, name, provider, ctx, opts) callback = function() M.stop(bufnr) stop_open_watcher(bufnr) - close_viewer(bufnr) + if not provider.detach then + close_viewer(bufnr) + end last_output[bufnr] = nil end, }) @@ -404,7 +406,9 @@ function M.compile(bufnr, name, provider, ctx, opts) once = true, callback = function() M.stop(bufnr) - close_viewer(bufnr) + if not provider.detach then + close_viewer(bufnr) + end last_output[bufnr] = nil end, }) @@ -507,7 +511,9 @@ function M.toggle(bufnr, name, provider, ctx_builder) callback = function() M.unwatch(bufnr) stop_open_watcher(bufnr) - close_viewer(bufnr) + if not provider.detach then + close_viewer(bufnr) + end opened[bufnr] = nil end, }) diff --git a/lua/preview/init.lua b/lua/preview/init.lua index fd54d71..322b893 100644 --- a/lua/preview/init.lua +++ b/lua/preview/init.lua @@ -10,6 +10,7 @@ ---@field clean? string[]|fun(ctx: preview.Context): string[] ---@field open? boolean|string[] ---@field reload? boolean|string[]|fun(ctx: preview.Context): string[] +---@field detach? boolean ---@class preview.Config ---@field debug boolean|string @@ -101,6 +102,7 @@ function M.setup(opts) end, 'false, "diagnostic", or "quickfix"') vim.validate(prefix .. '.open', provider.open, { 'boolean', 'table' }, true) vim.validate(prefix .. '.reload', provider.reload, { 'boolean', 'table', 'function' }, true) + vim.validate(prefix .. '.detach', provider.detach, 'boolean', true) end config = vim.tbl_deep_extend('force', default_config, { @@ -246,4 +248,8 @@ M._test = { end, } +if vim.g.preview then + M.setup(vim.g.preview) +end + return M From 7895b67c21d9cabcf9a3ff3bfb107754027127fa Mon Sep 17 00:00:00 2001 From: Barrett Ruth <62671086+barrettruth@users.noreply.github.com> Date: Wed, 4 Mar 2026 19:39:00 -0500 Subject: [PATCH 30/35] docs: replace all `setup()` references with `vim.g.preview` (#43) --- README.md | 12 ++++++------ doc/preview.nvim.txt | 34 +++++++++++++--------------------- 2 files changed, 19 insertions(+), 27 deletions(-) diff --git a/README.md b/README.md index 3bbb203..3ffbc38 100644 --- a/README.md +++ b/README.md @@ -49,7 +49,7 @@ luarocks install preview.nvim **Q: How do I define a custom provider?** ```lua -require('preview').setup({ +vim.g.preview = { rst = { cmd = { 'rst2html' }, args = function(ctx) @@ -59,15 +59,15 @@ require('preview').setup({ return ctx.file:gsub('%.rst$', '.html') end, }, -}) +} ``` **Q: How do I override a preset?** ```lua -require('preview').setup({ +vim.g.preview = { typst = { env = { TYPST_FONT_PATHS = '/usr/share/fonts' } }, -}) +} ``` **Q: How do I automatically open the output file?** @@ -77,7 +77,7 @@ open the output with `vim.ui.open()` after the first successful compilation in toggle/watch mode. For a specific application, pass a command table: ```lua -require('preview').setup({ +vim.g.preview = { typst = { open = { 'sioyek', '--new-instance' } }, -}) +} ``` diff --git a/doc/preview.nvim.txt b/doc/preview.nvim.txt index c64db62..2566301 100644 --- a/doc/preview.nvim.txt +++ b/doc/preview.nvim.txt @@ -23,8 +23,7 @@ REQUIREMENTS *preview.nvim-requirements ============================================================================== SETUP *preview.nvim-setup* -With lazy.nvim, set |vim.g.preview| in `init` so configuration is applied -before the plugin loads: >lua +Set |vim.g.preview| before the plugin loads: >lua { 'barrettruth/preview.nvim', init = function() @@ -32,19 +31,12 @@ before the plugin loads: >lua end, } < -Alternatively, call |preview.setup()| directly in a `config` function or -anywhere the plugin is already loaded. ============================================================================== CONFIGURATION *preview.nvim-configuration* -Configure via `require('preview').setup()`. - - *preview.setup()* -setup({opts?}) - - `opts` is a table where keys are preset names or filetypes. For each - key `k` with value `v` (excluding `debug`): +Configure by setting |vim.g.preview| to a table where keys are preset names +or filetypes. For each key `k` with value `v` (excluding `debug`): - If `k` is a preset name and `v` is `true`, the preset is registered as-is under its filetype. @@ -131,30 +123,30 @@ Context fields:~ Example enabling presets:~ >lua - require('preview').setup({ typst = true, latex = true, github = true }) + vim.g.preview = { typst = true, latex = true, github = true } < Example overriding a preset field:~ >lua - require('preview').setup({ + vim.g.preview = { typst = { open = { 'sioyek', '--new-instance' } }, - }) + } < Example overriding the output path (e.g. latexmk `$out_dir`):~ >lua - require('preview').setup({ + vim.g.preview = { latex = { output = function(ctx) return 'build/' .. vim.fn.fnamemodify(ctx.file, ':t:r') .. '.pdf' end, }, - }) + } < Example with a fully custom provider (key is not a preset name):~ >lua - require('preview').setup({ + vim.g.preview = { rst = { cmd = { 'rst2html' }, args = function(ctx) @@ -164,7 +156,7 @@ Example with a fully custom provider (key is not a preset name):~ return ctx.file:gsub('%.rst$', '.html') end, }, - }) + } < ============================================================================== @@ -184,14 +176,14 @@ Import them from `preview.presets`: Enable presets with `preset_name = true`: >lua - require('preview').setup({ typst = true, latex = true, github = true }) + vim.g.preview = { typst = true, latex = true, github = true } < Override individual fields by passing a table instead of `true`: >lua - require('preview').setup({ + vim.g.preview = { typst = { env = { TYPST_FONT_PATHS = '/usr/share/fonts' } }, - }) + } < ============================================================================== From 837c97cd09540d34d52afd035bdcd4b641fb1c21 Mon Sep 17 00:00:00 2001 From: Barrett Ruth <62671086+barrettruth@users.noreply.github.com> Date: Thu, 5 Mar 2026 01:38:29 -0500 Subject: [PATCH 31/35] docs: rewrite vimdoc to match `pending.txt` conventions (#44) Problem: The vimdoc used `preview.nvim.txt` filename and `*preview.nvim-xyz*` tags, inconsistent with other plugins. Solution: Rename to `preview.txt`, normalize tags to `*preview-xyz*`, add contents/install sections, and use `{field} (type)` formatting. --- doc/preview.nvim.txt | 277 ------------------------------------------- doc/preview.txt | 274 ++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 274 insertions(+), 277 deletions(-) delete mode 100644 doc/preview.nvim.txt create mode 100644 doc/preview.txt diff --git a/doc/preview.nvim.txt b/doc/preview.nvim.txt deleted file mode 100644 index 2566301..0000000 --- a/doc/preview.nvim.txt +++ /dev/null @@ -1,277 +0,0 @@ -*preview.nvim.txt* Async document compilation for Neovim - -Author: Barrett Ruth -License: MIT - -============================================================================== -INTRODUCTION *preview.nvim* - -preview.nvim is an extensible framework for compiling documents asynchronously -in Neovim. It provides a unified interface for any compilation workflow — -LaTeX, Typst, Markdown, or anything else with a CLI compiler. - -The plugin ships with opt-in presets for common tools (Typst, LaTeX, Pandoc, -AsciiDoc, Quarto) and supports fully custom providers. -See |preview.nvim-presets|. - -============================================================================== -REQUIREMENTS *preview.nvim-requirements* - -- Neovim >= 0.11.0 -- A compiler binary for each configured provider (e.g. `typst`, `latexmk`) - -============================================================================== -SETUP *preview.nvim-setup* - -Set |vim.g.preview| before the plugin loads: >lua - { - 'barrettruth/preview.nvim', - init = function() - vim.g.preview = { typst = true, latex = true } - end, - } -< - -============================================================================== -CONFIGURATION *preview.nvim-configuration* - -Configure by setting |vim.g.preview| to a table where keys are preset names -or filetypes. For each key `k` with value `v` (excluding `debug`): - - - If `k` is a preset name and `v` is `true`, the preset is registered - as-is under its filetype. - - If `k` is a preset name and `v` is a table, it is deep-merged with - the preset and registered under the preset's filetype. - - If `k` is not a preset name and `v` is a table, it is registered - directly as a custom provider keyed by filetype `k`. - - If `v` is `false`, the entry is skipped (no-op). - - See |preview.nvim-presets| for available preset names. - - Fields:~ - - `debug` boolean|string Enable debug logging. A string value - is treated as a log file path. - Default: `false` - - *preview.ProviderConfig* -Provider fields:~ - - `cmd` string[] The compiler command (required). - - `args` string[]|function Additional arguments. If a function, - receives a |preview.Context| and returns - a string[]. - - `cwd` string|function Working directory. If a function, - receives a |preview.Context|. Default: - git root or file directory. - - `env` table Environment variables. - - `output` string|function Output file path. If a function, - receives a |preview.Context|. - - `error_parser` function Receives (output, |preview.Context|) - and returns vim.Diagnostic[]. - - `errors` false|'diagnostic'|'quickfix' - How parse errors are reported. - `false` suppresses error handling. - `'quickfix'` populates the quickfix - list and opens it. Default: - `'diagnostic'`. - - `clean` string[]|function Command to remove build artifacts. - If a function, receives a - |preview.Context|. - - `open` boolean|string[] Open the output file after the first - successful compilation in toggle/watch - mode. `true` uses |vim.ui.open()|. A - string[] is run as a command with the - output path appended. When a string[] - is used the viewer process is tracked - and sent SIGTERM when the buffer is - deleted. `true` and single-instance - apps (e.g. Chrome) do not support - auto-close. - - `reload` boolean|string[]|function - Reload the output after recompilation. - `true` uses a built-in SSE server for - HTML files. A string[] is run as a - command. If a function, receives a - |preview.Context| and returns a - string[]. - - `detach` boolean When `true`, the viewer process opened - via a string[] `open` command is not - sent SIGTERM when the buffer is deleted. - Has no effect when `open` is `true`. - Default: `false`. - - *preview.Context* -Context fields:~ - - `bufnr` integer Buffer number. - `file` string Absolute file path. - `root` string Project root (git root or file directory). - `ft` string Filetype. - `output` string? Resolved output file path (set after `output` - is evaluated, available to `args` functions). - -Example enabling presets:~ ->lua - vim.g.preview = { typst = true, latex = true, github = true } -< - -Example overriding a preset field:~ ->lua - vim.g.preview = { - typst = { open = { 'sioyek', '--new-instance' } }, - } -< - -Example overriding the output path (e.g. latexmk `$out_dir`):~ ->lua - vim.g.preview = { - latex = { - output = function(ctx) - return 'build/' .. vim.fn.fnamemodify(ctx.file, ':t:r') .. '.pdf' - end, - }, - } -< - -Example with a fully custom provider (key is not a preset name):~ ->lua - vim.g.preview = { - rst = { - cmd = { 'rst2html' }, - args = function(ctx) - return { ctx.file } - end, - output = function(ctx) - return ctx.file:gsub('%.rst$', '.html') - end, - }, - } -< - -============================================================================== -PRESETS *preview.nvim-presets* - -preview.nvim ships with pre-built provider configurations for common tools. -Import them from `preview.presets`: - - `presets.typst` typst compile → PDF - `presets.latex` latexmk -pdf → PDF (with clean support) - `presets.pdflatex` pdflatex → PDF (single pass, no latexmk) - `presets.tectonic` tectonic → PDF (Rust-based LaTeX engine) - `presets.markdown` pandoc → HTML (standalone, embedded) - `presets.github` pandoc → HTML (GitHub-styled, `-f gfm` input) - `presets.asciidoctor` asciidoctor → HTML (AsciiDoc with SSE reload) - `presets.quarto` quarto render → HTML (scientific publishing) - -Enable presets with `preset_name = true`: ->lua - vim.g.preview = { typst = true, latex = true, github = true } -< - -Override individual fields by passing a table instead of `true`: ->lua - vim.g.preview = { - typst = { env = { TYPST_FONT_PATHS = '/usr/share/fonts' } }, - } -< - -============================================================================== -COMMANDS *preview.nvim-commands* - -:Preview [subcommand] *:Preview* - - Subcommands:~ - - `toggle` Toggle auto-compile on save (default if omitted). - `compile` One-shot compile of the current buffer. - `clean` Run the provider's clean command. - `open` Open the last compiled output without recompiling. - `status` Echo compilation status (idle, compiling, watching). - -============================================================================== -API *preview.nvim-api* - -preview.toggle({bufnr?}) *preview.toggle()* - Toggle auto-compile for the buffer. When enabled, the buffer is - immediately compiled and automatically recompiled on each save - (`BufWritePost`). Call again to stop. - -preview.compile({bufnr?}) *preview.compile()* - One-shot compile the document in the given buffer (default: current). - -preview.stop({bufnr?}) *preview.stop()* - Kill the active compilation process for the buffer. Programmatic - escape hatch — not exposed as a subcommand. - -preview.clean({bufnr?}) *preview.clean()* - Run the provider's clean command for the buffer. - -preview.open({bufnr?}) *preview.open()* - Open the last compiled output for the buffer without recompiling. - -preview.status({bufnr?}) *preview.status()* - Returns a |preview.Status| table. - -preview.statusline({bufnr?}) *preview.statusline()* - Returns a short status string for statusline integration: - `'compiling'`, `'watching'`, or `''` (idle). - - *preview.Status* -Status fields:~ - - `compiling` boolean Whether compilation is active. - `watching` boolean Whether auto-compile is active. - `provider` string? Name of the active provider. - `output_file` string? Path to the output file. - -preview.get_config() *preview.get_config()* - Returns the resolved |preview.Config|. - -============================================================================== -EVENTS *preview.nvim-events* - -preview.nvim fires User autocmds with structured data: - -`PreviewCompileStarted` Compilation began. - data: `{ bufnr, provider }` - -`PreviewCompileSuccess` Compilation succeeded (exit code 0). - data: `{ bufnr, provider, output }` - -`PreviewCompileFailed` Compilation failed (non-zero exit). - data: `{ bufnr, provider, code, stderr }` - -Example:~ ->lua - vim.api.nvim_create_autocmd('User', { - pattern = 'PreviewCompileSuccess', - callback = function(args) - local data = args.data - vim.notify('Compiled ' .. data.output .. ' with ' .. data.provider) - end, - }) -< - -============================================================================== -HEALTH *preview.nvim-health* - -Run `:checkhealth preview` to verify: - -- Neovim version >= 0.11.0 -- Each configured provider's binary is executable -- Each configured provider's opener binary (if any) is executable -- Each configured provider's filetype mapping is valid - -============================================================================== - vim:tw=78:ts=8:ft=help:norl: diff --git a/doc/preview.txt b/doc/preview.txt new file mode 100644 index 0000000..0e54a62 --- /dev/null +++ b/doc/preview.txt @@ -0,0 +1,274 @@ +*preview.txt* Async document compilation for Neovim + +Author: Barrett Ruth +License: MIT + +============================================================================== +INTRODUCTION *preview.nvim* + +preview.nvim is an extensible framework for compiling documents asynchronously +in Neovim. It provides a unified interface for any compilation workflow — +LaTeX, Typst, Markdown, or anything else with a CLI compiler. + +The plugin ships with opt-in presets for common tools (Typst, LaTeX, Pandoc, +AsciiDoc, Quarto) and supports fully custom providers. +See |preview-presets|. + +============================================================================== +CONTENTS *preview-contents* + + 1. Introduction ............................................. |preview.nvim| + 2. Requirements ..................................... |preview-requirements| + 3. Install ............................................... |preview-install| + 4. Configuration ........................................... |preview-config| + 5. Presets ............................................... |preview-presets| + 6. Commands ............................................. |preview-commands| + 7. Lua API ................................................... |preview-api| + 8. Events ............................................... |preview-events| + 9. Health ............................................... |preview-health| + +============================================================================== +REQUIREMENTS *preview-requirements* + +- Neovim >= 0.11.0 +- A compiler binary for each configured provider (e.g. `typst`, `latexmk`) + +============================================================================== +INSTALL *preview-install* + +Install with lazy.nvim: >lua + { 'barrettruth/preview.nvim' } +< + +No `setup()` call is needed. The plugin loads automatically when +|vim.g.preview| is set. See |preview-config|. + +============================================================================== +CONFIGURATION *preview-config* + +Configure by setting |vim.g.preview| to a table where keys are preset names +or filetypes. For each key `k` with value `v` (excluding `debug`): + + - If `k` is a preset name and `v` is `true`, the preset is registered + as-is under its filetype. + - If `k` is a preset name and `v` is a table, it is deep-merged with + the preset and registered under the preset's filetype. + - If `k` is not a preset name and `v` is a table, it is registered + directly as a custom provider keyed by filetype `k`. + - If `v` is `false`, the entry is skipped (no-op). + +See |preview-presets| for available preset names. + + *preview.ProviderConfig* +Provider fields: ~ + + {cmd} (string[]) Compiler command (required). + + {args} (string[]|function) Additional arguments. If a function, + receives a |preview.Context| and + returns a string[]. + + {cwd} (string|function) Working directory. If a function, + receives a |preview.Context|. + Default: git root or file directory. + + {env} (table) Environment variables. + + {output} (string|function) Output file path. If a function, + receives a |preview.Context|. + + {error_parser} (function) Receives (output, |preview.Context|) + and returns vim.Diagnostic[]. + + {errors} (false|'diagnostic'|'quickfix') + How parse errors are reported. + `false` suppresses error handling. + `'quickfix'` populates the quickfix + list and opens it. + Default: `'diagnostic'`. + + {clean} (string[]|function) Command to remove build artifacts. + If a function, receives a + |preview.Context|. + + {open} (boolean|string[]) Open the output file after the first + successful compilation in toggle/watch + mode. `true` uses |vim.ui.open()|. A + string[] is run as a command with the + output path appended. When a string[] + is used the viewer process is tracked + and sent SIGTERM when the buffer is + deleted. `true` and single-instance + apps (e.g. Chrome) do not support + auto-close. + + {reload} (boolean|string[]|function) + Reload the output after recompilation. + `true` uses a built-in SSE server for + HTML files. A string[] is run as a + command. If a function, receives a + |preview.Context| and returns a + string[]. + + {detach} (boolean) When `true`, the viewer process opened + via a string[] `open` command is not + sent SIGTERM when the buffer is + deleted. Has no effect when `open` is + `true`. Default: `false`. + + *preview.Context* +Context fields: ~ + + {bufnr} (integer) Buffer number. + {file} (string) Absolute file path. + {root} (string) Project root (git root or file directory). + {ft} (string) Filetype. + {output} (string?) Resolved output file path (set after `output` + is evaluated, available to `args` functions). + +Global options: ~ + + {debug} (boolean|string) Enable debug logging. A string value is treated + as a log file path. Default: `false`. + +Example enabling presets: >lua + vim.g.preview = { typst = true, latex = true, github = true } +< + +Example overriding a preset field: >lua + vim.g.preview = { + typst = { open = { 'sioyek', '--new-instance' } }, + } +< + +Example overriding the output path (e.g. latexmk `$out_dir`): >lua + vim.g.preview = { + latex = { + output = function(ctx) + return 'build/' .. vim.fn.fnamemodify(ctx.file, ':t:r') .. '.pdf' + end, + }, + } +< + +Example with a fully custom provider (key is not a preset name): >lua + vim.g.preview = { + rst = { + cmd = { 'rst2html' }, + args = function(ctx) + return { ctx.file } + end, + output = function(ctx) + return ctx.file:gsub('%.rst$', '.html') + end, + }, + } +< + +============================================================================== +PRESETS *preview-presets* + +Built-in provider configurations. Enable with `preset_name = true` or +override individual fields by passing a table instead: >lua + vim.g.preview = { typst = true, latex = true, github = true } +< + + `typst` typst compile → PDF + `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) + `asciidoctor` asciidoctor → HTML (AsciiDoc with SSE reload) + `quarto` quarto render → HTML (scientific publishing) + +============================================================================== +COMMANDS *preview-commands* + +:Preview [subcommand] *:Preview* + + Subcommands: ~ + + `toggle` Toggle auto-compile on save (default if omitted). + `compile` One-shot compile of the current buffer. + `clean` Run the provider's clean command. + `open` Open the last compiled output without recompiling. + `status` Echo compilation status (idle, compiling, watching). + +============================================================================== +LUA API *preview-api* + +preview.toggle({bufnr?}) *preview.toggle()* + Toggle auto-compile for the buffer. When enabled, the buffer is + immediately compiled and automatically recompiled on each save + (`BufWritePost`). Call again to stop. + +preview.compile({bufnr?}) *preview.compile()* + One-shot compile the document in the given buffer (default: current). + +preview.stop({bufnr?}) *preview.stop()* + Kill the active compilation process for the buffer. Programmatic + escape hatch — not exposed as a subcommand. + +preview.clean({bufnr?}) *preview.clean()* + Run the provider's clean command for the buffer. + +preview.open({bufnr?}) *preview.open()* + Open the last compiled output for the buffer without recompiling. + +preview.status({bufnr?}) *preview.status()* + Returns a |preview.Status| table. + +preview.statusline({bufnr?}) *preview.statusline()* + Returns a short status string for statusline integration: + `'compiling'`, `'watching'`, or `''` (idle). + +preview.get_config() *preview.get_config()* + Returns the resolved |preview.Config|. + + *preview.Status* +Status fields: ~ + + {compiling} (boolean) Whether compilation is active. + {watching} (boolean) Whether auto-compile is active. + {provider} (string?) Name of the active provider. + {output_file} (string?) Path to the output file. + +============================================================================== +EVENTS *preview-events* + +preview.nvim fires User autocmds with structured data: + +`PreviewCompileStarted` Compilation began. + data: `{ bufnr, provider }` + +`PreviewCompileSuccess` Compilation succeeded (exit code 0). + data: `{ bufnr, provider, output }` + +`PreviewCompileFailed` Compilation failed (non-zero exit). + data: `{ bufnr, provider, code, stderr }` + +Example: >lua + vim.api.nvim_create_autocmd('User', { + pattern = 'PreviewCompileSuccess', + callback = function(args) + local data = args.data + vim.notify('Compiled ' .. data.output .. ' with ' .. data.provider) + end, + }) +< + +============================================================================== +HEALTH *preview-health* + +Run |:checkhealth| preview to verify your setup: >vim + :checkhealth preview +< + +Checks: ~ +- Neovim version >= 0.11.0 +- Each configured provider's binary is executable +- Each configured provider's opener binary (if any) is executable + +============================================================================== + vim:tw=78:ts=8:ft=help:norl: From 23aa8acc55402d0a53fc5d14e0d2563a721f0eb0 Mon Sep 17 00:00:00 2001 From: Barrett Ruth <62671086+barrettruth@users.noreply.github.com> Date: Thu, 5 Mar 2026 09:32:33 -0500 Subject: [PATCH 32/35] feat: add `plantuml` preset (#45) Problem: PlantUML (`.puml`) diagrams have no built-in preview support, and Neovim lacks filetype detection for PlantUML files. Solution: Add a `plantuml` preset that compiles to SVG via `plantuml -tsvg`, with an error parser for `Error line N` diagnostics. Register `.puml` and `.pu` extensions via `vim.filetype.add` when the preset is configured. Add `plantuml` to the nix dev shell. --- flake.nix | 1 + lua/preview/init.lua | 6 ++++++ lua/preview/presets.lua | 31 +++++++++++++++++++++++++++++++ 3 files changed, 38 insertions(+) diff --git a/flake.nix b/flake.nix index 91a0ab2..4ae4479 100644 --- a/flake.nix +++ b/flake.nix @@ -32,6 +32,7 @@ pkgs.stylua pkgs.selene pkgs.lua-language-server + pkgs.plantuml ]; }; }); diff --git a/lua/preview/init.lua b/lua/preview/init.lua index 322b893..7cb982b 100644 --- a/lua/preview/init.lua +++ b/lua/preview/init.lua @@ -105,6 +105,12 @@ function M.setup(opts) vim.validate(prefix .. '.detach', provider.detach, 'boolean', true) end + if providers['plantuml'] then + vim.filetype.add({ + extension = { puml = 'plantuml', pu = 'plantuml' }, + }) + end + config = vim.tbl_deep_extend('force', default_config, { debug = debug, providers = providers, diff --git a/lua/preview/presets.lua b/lua/preview/presets.lua index 3591a21..e7c51e3 100644 --- a/lua/preview/presets.lua +++ b/lua/preview/presets.lua @@ -271,6 +271,37 @@ M.asciidoctor = { reload = true, } +---@type preview.ProviderConfig +M.plantuml = { + ft = 'plantuml', + cmd = { 'plantuml' }, + args = function(ctx) + return { '-tsvg', ctx.file } + end, + output = function(ctx) + return (ctx.file:gsub('%.puml$', '.svg')) + end, + error_parser = function(output) + local diagnostics = {} + for line in output:gmatch('[^\r\n]+') do + local lnum = line:match('^Error line (%d+) in file:') + if lnum then + table.insert(diagnostics, { + lnum = tonumber(lnum) - 1, + col = 0, + message = line, + severity = vim.diagnostic.severity.ERROR, + }) + end + end + return diagnostics + end, + clean = function(ctx) + return { 'rm', '-f', (ctx.file:gsub('%.puml$', '.svg')) } + end, + open = true, +} + ---@type preview.ProviderConfig M.quarto = { ft = 'quarto', From 31dcf9c91fb607ff3307b51992cef1c8b8a46ff3 Mon Sep 17 00:00:00 2001 From: Barrett Ruth <62671086+barrettruth@users.noreply.github.com> Date: Thu, 5 Mar 2026 10:44:33 -0500 Subject: [PATCH 33/35] feat: add `mermaid` preset (#46) Problem: no built-in support for compiling mermaid diagrams via `mmdc`. Solution: add a `mermaid` preset that compiles `.mmd` files to SVG and parses `Parse error on line N` diagnostics from stderr. Add `mermaid-cli` to the nix dev shell. --- flake.nix | 1 + lua/preview/presets.lua | 31 +++++++++++++++++++++++++++++++ 2 files changed, 32 insertions(+) diff --git a/flake.nix b/flake.nix index 4ae4479..d5bdae4 100644 --- a/flake.nix +++ b/flake.nix @@ -33,6 +33,7 @@ pkgs.selene pkgs.lua-language-server pkgs.plantuml + pkgs.mermaid-cli ]; }; }); diff --git a/lua/preview/presets.lua b/lua/preview/presets.lua index e7c51e3..1b5333e 100644 --- a/lua/preview/presets.lua +++ b/lua/preview/presets.lua @@ -302,6 +302,37 @@ M.plantuml = { open = true, } +---@type preview.ProviderConfig +M.mermaid = { + ft = 'mermaid', + cmd = { 'mmdc' }, + args = function(ctx) + return { '-i', ctx.file, '-o', ctx.output } + end, + output = function(ctx) + return (ctx.file:gsub('%.mmd$', '.svg')) + end, + error_parser = function(output) + local diagnostics = {} + for line in output:gmatch('[^\r\n]+') do + local lnum = line:match('^%s*Parse error on line (%d+)') + if lnum then + table.insert(diagnostics, { + lnum = tonumber(lnum) - 1, + col = 0, + message = line, + severity = vim.diagnostic.severity.ERROR, + }) + end + end + return diagnostics + end, + clean = function(ctx) + return { 'rm', '-f', (ctx.file:gsub('%.mmd$', '.svg')) } + end, + open = true, +} + ---@type preview.ProviderConfig M.quarto = { ft = 'quarto', From 6f090fdcf33155cb116b898109c6dcaa106e6e4a Mon Sep 17 00:00:00 2001 From: Barrett Ruth Date: Thu, 5 Mar 2026 10:55:03 -0500 Subject: [PATCH 34/35] build: split nix dev shell into `default` and `presets` MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Problem: the single dev shell mixed dev tooling (linters, test runner) with preset compiler tools, causing heavy rebuilds (e.g. Chromium for `mermaid-cli`) for contributors who only need the dev tools. Solution: extract dev tooling into a shared `devTools` list and expose two shells — `default` for development and `presets` for running all built-in preset compilers (`typst`, `texliveMedium`, `tectonic`, `pandoc`, `asciidoctor`, `quarto`, `plantuml`, `mermaid-cli`). --- flake.nix | 27 +++++++++++++++++++++------ 1 file changed, 21 insertions(+), 6 deletions(-) diff --git a/flake.nix b/flake.nix index 4ae4479..636f4d0 100644 --- a/flake.nix +++ b/flake.nix @@ -19,9 +19,9 @@ { formatter = forEachSystem (pkgs: pkgs.nixfmt-tree); - devShells = forEachSystem (pkgs: { - default = pkgs.mkShell { - packages = [ + devShells = forEachSystem (pkgs: + let + devTools = [ (pkgs.luajit.withPackages ( ps: with ps; [ busted @@ -32,9 +32,24 @@ pkgs.stylua pkgs.selene pkgs.lua-language-server - pkgs.plantuml ]; - }; - }); + in + { + default = pkgs.mkShell { + packages = devTools; + }; + presets = pkgs.mkShell { + packages = devTools ++ [ + pkgs.typst + pkgs.texliveMedium + pkgs.tectonic + pkgs.pandoc + pkgs.asciidoctor + pkgs.quarto + pkgs.plantuml + pkgs.mermaid-cli + ]; + }; + }); }; } From 9fe68dd159fd4f2405f57d5026ea40f0c7b251ae Mon Sep 17 00:00:00 2001 From: Barrett Ruth <62671086+barrettruth@users.noreply.github.com> Date: Thu, 5 Mar 2026 11:03:43 -0500 Subject: [PATCH 35/35] docs: document `plantuml` and `mermaid` presets (#47) Problem: the README and vimdoc presets list omitted `plantuml` and `mermaid` after both were added. Solution: add both presets to the vimdoc table and the README features blurb. --- README.md | 2 +- doc/preview.txt | 4 +++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 3ffbc38..80a2650 100644 --- a/README.md +++ b/README.md @@ -11,7 +11,7 @@ Typst, Markdown, etc.)—diagnostics included. - Async compilation via `vim.system()` - Built-in presets for Typst, LaTeX (latexmk, pdflatex, tectonic), Markdown, - GitHub-flavored Markdown, AsciiDoc, and Quarto + GitHub-flavored Markdown, AsciiDoc, PlantUML, Mermaid, and Quarto - Compiler errors via `vim.diagnostic` or quickfix - Previewer auto-close on buffer deletion diff --git a/doc/preview.txt b/doc/preview.txt index 0e54a62..383a8f5 100644 --- a/doc/preview.txt +++ b/doc/preview.txt @@ -11,7 +11,7 @@ in Neovim. It provides a unified interface for any compilation workflow — LaTeX, Typst, Markdown, or anything else with a CLI compiler. The plugin ships with opt-in presets for common tools (Typst, LaTeX, Pandoc, -AsciiDoc, Quarto) and supports fully custom providers. +AsciiDoc, PlantUML, Mermaid, Quarto) and supports fully custom providers. See |preview-presets|. ============================================================================== @@ -180,6 +180,8 @@ override individual fields by passing a table instead: >lua `markdown` pandoc → HTML (standalone, embedded) `github` pandoc → HTML (GitHub-styled, `-f gfm` input) `asciidoctor` asciidoctor → HTML (AsciiDoc with SSE reload) + `plantuml` plantuml → SVG (UML diagrams, `.puml`) + `mermaid` mmdc → SVG (Mermaid diagrams, `.mmd`) `quarto` quarto render → HTML (scientific publishing) ==============================================================================