feat(presets): add pdflatex, tectonic, asciidoctor, and quarto presets (#30)
* 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
This commit is contained in:
parent
c94df7c5d0
commit
180c672983
3 changed files with 314 additions and 0 deletions
|
|
@ -157,8 +157,12 @@ Import them from `preview.presets`:
|
||||||
|
|
||||||
`presets.typst` typst compile → PDF
|
`presets.typst` typst compile → PDF
|
||||||
`presets.latex` latexmk -pdf → PDF (with clean support)
|
`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.markdown` pandoc → HTML (standalone, embedded)
|
||||||
`presets.github` pandoc → HTML (GitHub-styled, `-f gfm` input)
|
`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`:
|
Enable presets with `preset_name = true`:
|
||||||
>lua
|
>lua
|
||||||
|
|
|
||||||
|
|
@ -93,6 +93,28 @@ local function parse_pandoc(output)
|
||||||
return diagnostics
|
return diagnostics
|
||||||
end
|
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
|
---@type preview.ProviderConfig
|
||||||
M.typst = {
|
M.typst = {
|
||||||
ft = 'typst',
|
ft = 'typst',
|
||||||
|
|
@ -137,6 +159,38 @@ M.latex = {
|
||||||
open = true,
|
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
|
---@type preview.ProviderConfig
|
||||||
M.markdown = {
|
M.markdown = {
|
||||||
ft = 'markdown',
|
ft = 'markdown',
|
||||||
|
|
@ -187,4 +241,42 @@ M.github = {
|
||||||
reload = true,
|
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
|
return M
|
||||||
|
|
|
||||||
|
|
@ -157,6 +157,112 @@ describe('presets', function()
|
||||||
end)
|
end)
|
||||||
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()
|
describe('markdown', function()
|
||||||
local md_ctx = {
|
local md_ctx = {
|
||||||
bufnr = 1,
|
bufnr = 1,
|
||||||
|
|
@ -341,4 +447,116 @@ describe('presets', function()
|
||||||
assert.are.same({}, diagnostics)
|
assert.are.same({}, diagnostics)
|
||||||
end)
|
end)
|
||||||
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)
|
end)
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue