Commit graph

28 commits

Author SHA1 Message Date
4665deee6c
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).
2026-03-04 13:54:01 -05:00
2a9110865b
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.
2026-03-04 13:53:38 -05:00
3a3a0783e8
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.
2026-03-04 13:53:01 -05:00
8ebe2ed80b
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.
2026-03-04 13:52:36 -05:00
Barrett Ruth
75b855438a
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.
2026-03-04 13:16:01 -05:00
Barrett Ruth
4732696a62
fix(compiler): one-shot builds and :Preview open provider config (#25)
* 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
2026-03-04 02:18:28 -05:00
Barrett Ruth
da3e3e4249
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
2026-03-04 00:20:41 -05:00
Barrett Ruth
54ef0c3c99
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.
2026-03-04 00:17:10 -05:00
Barrett Ruth
ec922a3564
fix(compiler): respect provider open config in M.open() (#23)
* 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.
2026-03-03 17:59:58 -05:00
Barrett Ruth
e661ea78e8
fix(reload): bind SSE server to port 0 for OS-assigned port (#21)
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.
2026-03-03 17:46:04 -05:00
Barrett Ruth
62961c8541
feat: unified reload field for live-preview (SSE + long-running watch) (#19)
* 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 </body> (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.
2026-03-03 16:41:47 -05:00
Barrett Ruth
99263dec9f
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.
2026-03-03 15:12:14 -05:00
Barrett Ruth
4c22f84b31
feat(init): validate provider config eagerly in setup (#16) 2026-03-03 15:04:03 -05:00
Barrett Ruth
7ed4b61c98
refactor(commands): derive completion from dispatch table (#15) 2026-03-03 15:03:48 -05:00
Barrett Ruth
253ca05da3
feat(compiler): add configurable error output modes (#14)
Problem: all parse errors went to vim.diagnostic with no way to silence
them or route them to the quickfix list. Users wanting quickfix-style
error navigation had no option.

Solution: add an errors field to ProviderConfig accepting false,
'diagnostic' (default), or 'quickfix'. false suppresses error handling
entirely. 'quickfix' converts parsed diagnostics to qflist items
(1-indexed), calls setqflist, and opens the window. On success,
'quickfix' mode clears the qflist the same way 'diagnostic' mode clears
vim.diagnostic.
2026-03-03 14:57:44 -05:00
Barrett Ruth
7995d6422d
feat(compiler): notify on toggle watch start and stop (#13)
Problem: :Preview toggle gave no feedback, leaving the user to guess
whether watching was enabled or disabled.

Solution: emit vim.notify messages when toggling on ("watching with
\"<provider>\"") and off ("watching stopped"). Also normalize the
[preview.nvim] prefix in commands.lua to include the colon.
2026-03-03 14:18:28 -05:00
Barrett Ruth
0f353446b6
fix(presets): correct error parsers for real compiler output (#11)
Problem: all three built-in error parsers were broken against real
compiler output. Typst set source to the relative file path, overriding
the provider name. LaTeX errors go to stdout but the parser only
received stderr. Pandoc's pattern matched "Error at" but not the real
"Error parsing YAML metadata at" format, and single-line parsing missed
multiline messages.

Solution: pass combined stdout+stderr to error_parser so LaTeX stdout
errors are visible. Remove source = file from the Typst parser so
diagnostic.lua defaults it to the provider name. Rewrite the Pandoc
parser with line-based lookahead: match (line N, column N) regardless
of prefix text, skip YAML parse exception lines when looking ahead for
the human-readable message. Rename stderr param to output throughout
diagnostic.lua, presets.lua, and init.lua annotations.
2026-03-03 14:14:59 -05:00
Barrett Ruth
277daa63ca
feat(presets): add error parsers for built-in presets (#9)
Problem: none of the four presets defined an error_parser, so the
diagnostic infrastructure went unused out of the box.

Solution: add parsers for typst (file:line:col short format), latexmk
(pdflatex file-line-error + summary), and pandoc (parse errors, YAML
exceptions, generic errors). Enable machine-parseable output flags in
typst and latex args. Pandoc parser is shared between markdown and
github presets.
2026-03-03 13:42:59 -05:00
Barrett Ruth
b00b169bf5
feat(compiler): debounce compilation in toggle mode (#8)
Problem: in toggle mode, each BufWritePost immediately spawned a new
compilation, killing any in-flight process. Rapid saves wasted cycles
on compilers like latexmk.

Solution: add a 500ms debounce timer per buffer. The BufWritePost
callback starts/restarts the timer instead of compiling immediately.
Timers are cleaned up on unwatch and BufWipeout.
2026-03-03 13:42:44 -05:00
Barrett Ruth
bf2f4a78e2
feat: add statusline function (#10)
Problem: no way to expose compiling/watching state to statusline
plugins like lualine or heirline without polling status() and
formatting it manually.

Solution: add `require('preview').statusline()` that returns
'compiling', 'watching', or '' for direct use in statusline components.
2026-03-03 13:37:36 -05:00
Barrett Ruth
187474bb3d
refactor(presets): replace xdg-open with vim.ui.open (#7)
Problem: all presets hardcoded `open = { 'xdg-open' }`, making them
Linux-only. The compiler already handles `open = true` via
`vim.ui.open()`, which is cross-platform.

Solution: change all four presets to `open = true`.
2026-03-03 13:37:16 -05:00
Barrett Ruth
cfe101c6c4
feat(commands): add :Preview open subcommand (#6)
Problem: after closing a viewer, there was no way to re-open the last
compiled output without recompiling.

Solution: track the most recent output file per buffer in a `last_output`
table that persists after compilation finishes. Add `compiler.open()`,
`M.open()`, and wire it into the command dispatch.
2026-03-03 13:37:02 -05:00
Barrett Ruth
0b16ff7178
Refactor/preset name true syntax (#4)
* refactor(config): replace array preset syntax with preset_name = true

Problem: setup() mixed array entries (preset names) and hash entries
(custom providers keyed by filetype), requiring verbose
vim.tbl_deep_extend boilerplate to override presets.

Solution: unify under a single key=value model. Keys are preset names
or filetypes; true registers the preset as-is, a table deep-merges
with the matching preset (or registers a custom provider if no preset
matches), and false is a no-op. Array entries are dropped. Also adds
-f gfm to presets.github args so pandoc parses input as GFM.

* ci: format

* fix(presets): parenthesize gsub output to suppress redundant-return-value

* ci: remove superfluous things

* refactor: remove PreviewWatch* events and clean up docs

Problem: PreviewWatchStarted/PreviewWatchStopped were redundant with
the status() API, and the doc had a wrong author, stale INSTALLATION
format, and "watch mode" language left over from the watch → toggle
rename.

Solution: Remove the events and their tests. Fix the doc author,
rename INSTALLATION → SETUP to match sibling plugins, replace "watch
mode" with "auto-compile" throughout, and drop the events from EVENTS.
2026-03-03 00:49:10 -05:00
Barrett Ruth
2d212aa220
refactor(config): replace array preset syntax with preset_name = true (#3)
* refactor(config): replace array preset syntax with preset_name = true

Problem: setup() mixed array entries (preset names) and hash entries
(custom providers keyed by filetype), requiring verbose
vim.tbl_deep_extend boilerplate to override presets.

Solution: unify under a single key=value model. Keys are preset names
or filetypes; true registers the preset as-is, a table deep-merges
with the matching preset (or registers a custom provider if no preset
matches), and false is a no-op. Array entries are dropped. Also adds
-f gfm to presets.github args so pandoc parses input as GFM.

* ci: format

* fix(presets): parenthesize gsub output to suppress redundant-return-value
2026-03-03 00:25:49 -05:00
673573044f
feat: rename watch → toggle, auto-compile on start, built-in opener
Problem: :Preview watch only registered a BufWritePost autocmd without
compiling immediately, required boilerplate to open output files after
first compilation, and was misleadingly named.

Solution: Rename watch → toggle throughout. M.toggle now compiles
immediately on activation. Add an open field to ProviderConfig: true
calls vim.ui.open(), a string[] runs the command with the output path
appended, tracked per-buffer so the file opens only once. All presets
default to { 'xdg-open' }. Health check validates opener binaries.
Guard the async compile callback against invalid buffer ids.
2026-03-02 23:37:44 -05:00
942438f817
feat: rename 2026-03-02 21:23:40 -05:00
e1d7abf58e
ci: type checking
Some checks are pending
luarocks / quality (push) Waiting to run
luarocks / publish (push) Blocked by required conditions
2026-03-01 17:28:00 -05:00
e49a664d48
ci: format 2026-03-01 17:22:59 -05:00