From 8ebe2ed80b8871ae6fe9ba6ca5f7553612cc6d1e Mon Sep 17 00:00:00 2001 From: Barrett Ruth Date: Wed, 4 Mar 2026 13:52:36 -0500 Subject: [PATCH 1/5] 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. --- doc/preview.nvim.txt | 1 + lua/preview/presets.lua | 16 ++++++++++++ spec/presets_spec.lua | 55 +++++++++++++++++++++++++++++++++++++++++ 3 files changed, 72 insertions(+) diff --git a/doc/preview.nvim.txt b/doc/preview.nvim.txt index a3d4981..be89cde 100644 --- a/doc/preview.nvim.txt +++ b/doc/preview.nvim.txt @@ -157,6 +157,7 @@ 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.markdown` pandoc → HTML (standalone, embedded) `presets.github` pandoc → HTML (GitHub-styled, `-f gfm` input) diff --git a/lua/preview/presets.lua b/lua/preview/presets.lua index fc20f89..88e84e8 100644 --- a/lua/preview/presets.lua +++ b/lua/preview/presets.lua @@ -137,6 +137,22 @@ 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.markdown = { ft = 'markdown', diff --git a/spec/presets_spec.lua b/spec/presets_spec.lua index 8085a3c..8e9e89b 100644 --- a/spec/presets_spec.lua +++ b/spec/presets_spec.lua @@ -157,6 +157,61 @@ 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('markdown', function() local md_ctx = { bufnr = 1, From 3a3a0783e832e0a40ef4159b59f368a62a6eece0 Mon Sep 17 00:00:00 2001 From: Barrett Ruth Date: Wed, 4 Mar 2026 13:53:01 -0500 Subject: [PATCH 2/5] 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. --- doc/preview.nvim.txt | 1 + lua/preview/presets.lua | 16 +++++++++++++ spec/presets_spec.lua | 51 +++++++++++++++++++++++++++++++++++++++++ 3 files changed, 68 insertions(+) diff --git a/doc/preview.nvim.txt b/doc/preview.nvim.txt index be89cde..a3021e4 100644 --- a/doc/preview.nvim.txt +++ b/doc/preview.nvim.txt @@ -158,6 +158,7 @@ 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) diff --git a/lua/preview/presets.lua b/lua/preview/presets.lua index 88e84e8..1d573e1 100644 --- a/lua/preview/presets.lua +++ b/lua/preview/presets.lua @@ -153,6 +153,22 @@ M.pdflatex = { 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', diff --git a/spec/presets_spec.lua b/spec/presets_spec.lua index 8e9e89b..c4394c5 100644 --- a/spec/presets_spec.lua +++ b/spec/presets_spec.lua @@ -212,6 +212,57 @@ describe('presets', function() 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, From 2a9110865b34a1bec087670174271ff0451f6d5d Mon Sep 17 00:00:00 2001 From: Barrett Ruth Date: Wed, 4 Mar 2026 13:53:38 -0500 Subject: [PATCH 3/5] feat(presets): add asciidoctor preset MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- doc/preview.nvim.txt | 1 + lua/preview/presets.lua | 43 +++++++++++++++++++++++++ spec/presets_spec.lua | 71 +++++++++++++++++++++++++++++++++++++++++ 3 files changed, 115 insertions(+) diff --git a/doc/preview.nvim.txt b/doc/preview.nvim.txt index a3021e4..e946126 100644 --- a/doc/preview.nvim.txt +++ b/doc/preview.nvim.txt @@ -161,6 +161,7 @@ Import them from `preview.presets`: `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) Enable presets with `preset_name = true`: >lua diff --git a/lua/preview/presets.lua b/lua/preview/presets.lua index 1d573e1..8891c11 100644 --- a/lua/preview/presets.lua +++ b/lua/preview/presets.lua @@ -93,6 +93,29 @@ 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', @@ -219,4 +242,24 @@ 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, +} + return M diff --git a/spec/presets_spec.lua b/spec/presets_spec.lua index c4394c5..55a00c4 100644 --- a/spec/presets_spec.lua +++ b/spec/presets_spec.lua @@ -447,4 +447,75 @@ 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) end) From 4665deee6c87e15bd8fd789fe724be68944a31ac Mon Sep 17 00:00:00 2001 From: Barrett Ruth Date: Wed, 4 Mar 2026 13:54:01 -0500 Subject: [PATCH 4/5] 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). --- doc/preview.nvim.txt | 1 + lua/preview/presets.lua | 18 ++++++++++++++++ spec/presets_spec.lua | 48 +++++++++++++++++++++++++++++++++++++++++ 3 files changed, 67 insertions(+) diff --git a/doc/preview.nvim.txt b/doc/preview.nvim.txt index e946126..914f72d 100644 --- a/doc/preview.nvim.txt +++ b/doc/preview.nvim.txt @@ -162,6 +162,7 @@ Import them from `preview.presets`: `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 8891c11..16e41ca 100644 --- a/lua/preview/presets.lua +++ b/lua/preview/presets.lua @@ -262,4 +262,22 @@ M.asciidoctor = { 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 55a00c4..9ce2cfc 100644 --- a/spec/presets_spec.lua +++ b/spec/presets_spec.lua @@ -518,4 +518,52 @@ describe('presets', 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 f408136d5a019abc32273486309a676b3e8b27fe Mon Sep 17 00:00:00 2001 From: Barrett Ruth Date: Wed, 4 Mar 2026 13:54:19 -0500 Subject: [PATCH 5/5] refactor: apply stylua formatting to new preset code --- lua/preview/presets.lua | 3 +-- spec/presets_spec.lua | 13 +++---------- 2 files changed, 4 insertions(+), 12 deletions(-) diff --git a/lua/preview/presets.lua b/lua/preview/presets.lua index 16e41ca..8a23766 100644 --- a/lua/preview/presets.lua +++ b/lua/preview/presets.lua @@ -98,8 +98,7 @@ end 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+): (.+)$') + local severity, _, lnum, msg = line:match('^asciidoctor: (%u+): (.+): line (%d+): (.+)$') if lnum then local sev = vim.diagnostic.severity.ERROR if severity == 'WARNING' then diff --git a/spec/presets_spec.lua b/spec/presets_spec.lua index 9ce2cfc..ab030f0 100644 --- a/spec/presets_spec.lua +++ b/spec/presets_spec.lua @@ -477,10 +477,7 @@ describe('presets', function() end) it('returns clean command', function() - assert.are.same( - { 'rm', '-f', '/tmp/document.html' }, - presets.asciidoctor.clean(adoc_ctx) - ) + assert.are.same({ 'rm', '-f', '/tmp/document.html' }, presets.asciidoctor.clean(adoc_ctx)) end) it('has open enabled', function() @@ -498,16 +495,12 @@ describe('presets', function() 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('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 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)