Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
54 changes: 28 additions & 26 deletions doc/preview.txt
Original file line number Diff line number Diff line change
Expand Up @@ -183,8 +183,8 @@ override individual fields by passing a table instead: >lua
`latex` latexmk -pdf → PDF (with clean support)
`pdflatex` pdflatex → PDF (single pass, no latexmk)
`tectonic` tectonic → PDF (Rust-based LaTeX engine)
`markdown` pandoc → HTML (standalone, embedded)
`github` pandoc → HTML (GitHub-styled, `-f gfm` input)
`markdown` pandoc → HTML (standalone, KaTeX math)
`github` pandoc → HTML (GitHub-styled, `-f gfm`, KaTeX math)
`asciidoctor` asciidoctor → HTML (AsciiDoc with SSE reload)
`plantuml` plantuml → SVG (UML diagrams, `.puml`)
`mermaid` mmdc → SVG (Mermaid diagrams, `.mmd`)
Expand All @@ -193,34 +193,36 @@ override individual fields by passing a table instead: >lua
Math rendering (pandoc presets): ~
*preview-math*

The `markdown` and `github` presets use `--mathml` by default, which converts
TeX math to native MathML markup rendered by the browser. This is the only
math option compatible with `--embed-resources` (self-contained HTML).
The `markdown` and `github` presets use `--katex` by default, which inserts a
`<script>` tag that loads KaTeX from a CDN at view time. The browser fetches
the assets once and caches them, so math renders instantly on subsequent loads.
Requires internet on first view.

`--mathjax` and `--katex` insert `<script>` tags that load JavaScript and
fonts from a CDN at runtime. Pandoc's `--embed-resources` cannot inline these
dynamic dependencies, so math fails to render in the output.
For offline use, swap in `--mathml` via `extra_args`. MathML is rendered
natively by the browser with no external dependencies: >lua

To use KaTeX or MathJax instead, override `args` to drop `--embed-resources`
(the output will require internet access). For example, to work with
github-flavored markdown (gfm): >lua
vim.g.preview = {
github = { extra_args = { '--mathml' } },
}
<

Note: pandoc's math flags (`--katex`, `--mathml`, `--mathjax`) are mutually
exclusive — last flag wins. Adding `--mathml` via `extra_args` (which is
appended after `args`) overrides `--katex`.

Self-contained output with `--embed-resources`: >lua

vim.g.preview = {
github = {
args = function(ctx)
return {
'-f',
'gfm',
ctx.file,
'-s',
'--katex',
'--css',
'https://cdn.jsdelivr.net/gh/pixelbrackets/gfm-stylesheet@master/dist/gfm.css',
'-o',
ctx.output,
}
end,
},
github = { extra_args = { '--embed-resources' } },
}
<

This inlines all external resources into the HTML. With `--katex` this adds
~15s of compile time per save (pandoc fetches KaTeX from the CDN during
compilation). Pair with `--mathml` to avoid the penalty: >lua

vim.g.preview = {
github = { extra_args = { '--embed-resources', '--mathml' } },
}
<

Expand Down
78 changes: 74 additions & 4 deletions lua/preview/compiler.lua
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@ local log = require('preview.log')
---@field viewer? table
---@field viewer_open? boolean
---@field open_watcher? uv.uv_fs_event_t
---@field output_watcher? uv.uv_fs_event_t
---@field has_errors? boolean
---@field debounce? uv.uv_timer_t
---@field bwp_autocmd? integer
---@field unload_autocmd? integer
Expand Down Expand Up @@ -41,6 +43,17 @@ local function stop_open_watcher(bufnr)
s.open_watcher = nil
end

---@param bufnr integer
local function stop_output_watcher(bufnr)
local s = state[bufnr]
if not (s and s.output_watcher) then
return
end
s.output_watcher:stop()
s.output_watcher:close()
s.output_watcher = nil
end

---@param bufnr integer
local function close_viewer(bufnr)
local s = state[bufnr]
Expand All @@ -56,16 +69,17 @@ end
---@param provider preview.ProviderConfig
---@param ctx preview.Context
---@param output string
---@return integer
local function handle_errors(bufnr, name, provider, ctx, output)
local errors_mode = provider.errors
if errors_mode == nil then
errors_mode = 'diagnostic'
end
if not (provider.error_parser and errors_mode) then
return
return 0
end
if errors_mode == 'diagnostic' then
diagnostic.set(bufnr, name, provider.error_parser, output, ctx)
return diagnostic.set(bufnr, name, provider.error_parser, output, ctx)
elseif errors_mode == 'quickfix' then
local ok, diags = pcall(provider.error_parser, output, ctx)
if ok and diags and #diags > 0 then
Expand All @@ -83,8 +97,10 @@ local function handle_errors(bufnr, name, provider, ctx, output)
local win = vim.fn.win_getid()
vim.cmd.cwindow()
vim.fn.win_gotoid(win)
return #diags
end
end
return 0
end

---@param bufnr integer
Expand Down Expand Up @@ -169,6 +185,7 @@ local function stop_watching(bufnr, s)
s.watching = false
M.stop(bufnr)
stop_open_watcher(bufnr)
stop_output_watcher(bufnr)
close_viewer(bufnr)
s.viewer_open = nil
if s.bwp_autocmd then
Expand Down Expand Up @@ -250,7 +267,11 @@ function M.compile(bufnr, name, provider, ctx, opts)
return
end
stderr_acc[#stderr_acc + 1] = data
handle_errors(bufnr, name, provider, ctx, table.concat(stderr_acc))
local count = handle_errors(bufnr, name, provider, ctx, table.concat(stderr_acc))
if count > 0 and not s.has_errors then
s.has_errors = true
vim.notify('[preview.nvim]: compilation failed', vim.log.levels.ERROR)
end
end),
},
vim.schedule_wrap(function(result)
Expand All @@ -263,6 +284,7 @@ function M.compile(bufnr, name, provider, ctx, opts)
end
if result.code ~= 0 then
log.dbg('long-running process failed for buffer %d (exit code %d)', bufnr, result.code)
vim.notify('[preview.nvim]: compilation failed', vim.log.levels.ERROR)
handle_errors(bufnr, name, provider, ctx, (result.stdout or '') .. (result.stderr or ''))
vim.api.nvim_exec_autocmds('User', {
pattern = 'PreviewCompileFailed',
Expand Down Expand Up @@ -325,10 +347,54 @@ function M.compile(bufnr, name, provider, ctx, opts)
end
end

if output_file ~= '' then
local out_dir = vim.fn.fnamemodify(output_file, ':h')
local out_name = vim.fn.fnamemodify(output_file, ':t')
stop_output_watcher(bufnr)
local ow = vim.uv.new_fs_event()
if ow then
s.output_watcher = ow
local last_mtime = 0
local stat = vim.uv.fs_stat(output_file)
if stat then
last_mtime = stat.mtime.sec
end
ow:start(
out_dir,
{},
vim.schedule_wrap(function(err, filename, _events)
if err or vim.fn.fnamemodify(filename or '', ':t') ~= out_name then
return
end
if not vim.api.nvim_buf_is_valid(bufnr) then
stop_output_watcher(bufnr)
return
end
local new_stat = vim.uv.fs_stat(output_file)
if not (new_stat and new_stat.mtime.sec > last_mtime) then
return
end
last_mtime = new_stat.mtime.sec
log.dbg('output updated for buffer %d', bufnr)
vim.notify('[preview.nvim]: compilation complete', vim.log.levels.INFO)
stderr_acc = {}
s.has_errors = false
clear_errors(bufnr, provider)
vim.api.nvim_exec_autocmds('User', {
pattern = 'PreviewCompileSuccess',
data = { bufnr = bufnr, provider = name, output = output_file },
})
end)
)
end
end

s.process = obj
s.provider = name
s.is_reload = true
s.has_errors = false

vim.notify('[preview.nvim]: compiling...', vim.log.levels.INFO)
vim.api.nvim_exec_autocmds('User', {
pattern = 'PreviewCompileStarted',
data = { bufnr = bufnr, provider = name },
Expand Down Expand Up @@ -360,6 +426,7 @@ function M.compile(bufnr, name, provider, ctx, opts)
end
if result.code == 0 then
log.dbg('compilation succeeded for buffer %d', bufnr)
vim.notify('[preview.nvim]: compilation complete', vim.log.levels.INFO)
clear_errors(bufnr, provider)
vim.api.nvim_exec_autocmds('User', {
pattern = 'PreviewCompileSuccess',
Expand All @@ -385,6 +452,7 @@ function M.compile(bufnr, name, provider, ctx, opts)
end
else
log.dbg('compilation failed for buffer %d (exit code %d)', bufnr, result.code)
vim.notify('[preview.nvim]: compilation failed', vim.log.levels.ERROR)
handle_errors(bufnr, name, provider, ctx, (result.stdout or '') .. (result.stderr or ''))
vim.api.nvim_exec_autocmds('User', {
pattern = 'PreviewCompileFailed',
Expand All @@ -403,6 +471,7 @@ function M.compile(bufnr, name, provider, ctx, opts)
s.provider = name
s.is_reload = false

vim.notify('[preview.nvim]: compiling...', vim.log.levels.INFO)
vim.api.nvim_exec_autocmds('User', {
pattern = 'PreviewCompileStarted',
data = { bufnr = bufnr, provider = name },
Expand All @@ -415,6 +484,7 @@ function M.stop(bufnr)
if not s then
return
end
stop_output_watcher(bufnr)
local obj = s.process
if not obj then
return
Expand Down Expand Up @@ -480,6 +550,7 @@ function M.toggle(bufnr, name, provider, ctx_builder)
callback = function()
M.stop(bufnr)
stop_open_watcher(bufnr)
stop_output_watcher(bufnr)
if not provider.detach then
close_viewer(bufnr)
end
Expand Down Expand Up @@ -512,7 +583,6 @@ function M.toggle(bufnr, name, provider, ctx_builder)
log.dbg('watching buffer %d with provider "%s"', bufnr, name)
end

vim.notify('[preview.nvim]: watching with "' .. name .. '"', vim.log.levels.INFO)
M.compile(bufnr, name, provider, ctx_builder(bufnr))
end

Expand Down
6 changes: 4 additions & 2 deletions lua/preview/diagnostic.lua
Original file line number Diff line number Diff line change
Expand Up @@ -15,21 +15,23 @@ end
---@param error_parser fun(output: string, ctx: preview.Context): preview.Diagnostic[]
---@param output string
---@param ctx preview.Context
---@return integer
function M.set(bufnr, name, error_parser, output, ctx)
local ok, diagnostics = pcall(error_parser, output, ctx)
if not ok then
log.dbg('error_parser for "%s" failed: %s', name, diagnostics)
return
return 0
end
if not diagnostics or #diagnostics == 0 then
log.dbg('error_parser for "%s" returned no diagnostics', name)
return
return 0
end
for _, d in ipairs(diagnostics) do
d.source = d.source or name
end
vim.diagnostic.set(ns, bufnr, diagnostics)
log.dbg('set %d diagnostics for buffer %d from provider "%s"', #diagnostics, bufnr, name)
return #diagnostics
end

---@return integer
Expand Down
5 changes: 2 additions & 3 deletions lua/preview/presets.lua
Original file line number Diff line number Diff line change
Expand Up @@ -224,7 +224,7 @@ M.markdown = {
ft = 'markdown',
cmd = { 'pandoc' },
args = function(ctx)
return { ctx.file, '-s', '--embed-resources', '--mathml', '-o', ctx.output }
return { ctx.file, '-s', '--katex', '-o', ctx.output }
end,
output = function(ctx)
return (ctx.file:gsub('%.md$', '.html'))
Expand All @@ -249,8 +249,7 @@ M.github = {
'gfm',
ctx.file,
'-s',
'--embed-resources',
'--mathml',
'--katex',
'--css',
'https://cdn.jsdelivr.net/gh/pixelbrackets/gfm-stylesheet@master/dist/gfm.css',
'-o',
Expand Down
62 changes: 62 additions & 0 deletions spec/compiler_spec.lua
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,14 @@ describe('compiler', function()
end,
})

local notified = false
local orig = vim.notify
vim.notify = function(msg)
if msg:find('compiling') then
notified = true
end
end

local provider = { cmd = { 'echo', 'ok' } }
local ctx = {
bufnr = bufnr,
Expand All @@ -64,7 +72,9 @@ describe('compiler', function()
}

compiler.compile(bufnr, 'echo', provider, ctx)
vim.notify = orig
assert.is_true(fired)
assert.is_true(notified)

vim.wait(2000, function()
return process_done(bufnr)
Expand Down Expand Up @@ -269,6 +279,58 @@ describe('compiler', function()
end)
end)

describe('long-running notifications', function()
it('notifies failure on stderr diagnostics', function()
local bufnr = helpers.create_buffer({ 'hello' }, 'text')
vim.api.nvim_buf_set_name(bufnr, '/tmp/preview_test_longrun.txt')
vim.bo[bufnr].modified = false

local notified_fail = false
local orig = vim.notify
vim.notify = function(msg, level)
if msg:find('compilation failed') and level == vim.log.levels.ERROR then
notified_fail = true
end
end

local provider = {
cmd = { 'sh' },
reload = function()
return { 'sh', '-c', 'echo "error: bad input" >&2; sleep 60' }
end,
error_parser = function()
return {
{ lnum = 0, col = 0, message = 'bad input', severity = vim.diagnostic.severity.ERROR },
}
end,
}
local ctx = {
bufnr = bufnr,
file = '/tmp/preview_test_longrun.txt',
root = '/tmp',
ft = 'text',
}

compiler.compile(bufnr, 'testprov', provider, ctx)

vim.wait(3000, function()
return notified_fail
end, 50)

vim.notify = orig
assert.is_true(notified_fail)

local s = compiler._test.state[bufnr]
assert.is_true(s.has_errors)

compiler.stop(bufnr)
vim.wait(2000, function()
return process_done(bufnr)
end, 50)
helpers.delete_buffer(bufnr)
end)
end)

describe('stop', function()
it('does nothing when no process is active', function()
assert.has_no.errors(function()
Expand Down
Loading
Loading