Skip to content
Closed
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
37 changes: 37 additions & 0 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
name: Tests

on:
push:
branches: ["*"]
pull_request:

jobs:
test:
runs-on: ubuntu-latest
strategy:
matrix:
nvim_version: ["stable", "nightly"]
steps:
- uses: actions/checkout@v4

- name: Install Neovim
uses: rhysd/action-setup-vim@v1
with:
neovim: true
version: ${{ matrix.nvim_version }}

- name: Run tests
run: |
set -euo pipefail
found=0
for f in tests/*_test.lua; do
[ -f "$f" ] || continue
found=1
echo "::group::$(basename "$f")"
nvim --headless -c "set rtp+=." -c "luafile $f" -c "qa!" 2>&1
echo "::endgroup::"
done
if [ "$found" -eq 0 ]; then
echo "No test files found in tests/"
exit 1
fi
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1 +1,2 @@
.claude
.envrc
29 changes: 28 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
# markdown-preview.nvim

![Tests](https://github.com/selimacerbas/markdown-preview.nvim/actions/workflows/test.yml/badge.svg)

> **Note:** This repository was previously known as `mermaid-playground.nvim`. It has been renamed and rewritten to support full Markdown preview alongside first-class Mermaid diagram support.

Live **Markdown preview** for Neovim with first-class **Mermaid diagram** support.
Expand Down Expand Up @@ -96,7 +98,7 @@ The preview opens a polished browser app with:

```lua
require("markdown_preview").setup({
instance_mode = "takeover", -- "takeover" or "multi" (see below)
host = "127.0.0.1", -- bind address ("0.0.0.0" to allow external connections)
port = 0, -- 0 = auto (8421 for takeover, OS-assigned for multi)
open_browser = true, -- auto-open browser on start

Expand Down Expand Up @@ -136,6 +138,23 @@ require("markdown_preview").setup({
})
```

### Network

By default the server listens on `127.0.0.1` (localhost only). Set `host` to `"0.0.0.0"` to allow connections from other machines on your network — useful for previewing on a phone or second device.

**⚠️ Warning:** Using `host = "0.0.0.0"` exposes your preview to your local network. Only use this in trusted networks and avoid opening sensitive content to public access.

```lua
require("markdown_preview").setup({
host = "0.0.0.0", -- accessible from any network interface
})
```

The `port` option controls which port the server binds to:

- `0` (default) — automatic: port `8421` in takeover mode, OS-assigned in multi mode
- Any specific number — binds to that port (errors if already in use)

### Hooks

Lifecycle callbacks that run when the preview starts or stops. Use them for notifications, logging, or triggering other actions.
Expand Down Expand Up @@ -253,6 +272,14 @@ Browser-side libraries are loaded from CDN (cached by your browser):

---

## Running tests

```sh
nvim --headless -c "set rtp+=." -c "luafile tests/host_config_test.lua" -c "qa!"
```

---

## Project structure

```
Expand Down
16 changes: 16 additions & 0 deletions TESTING.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
# Testing

## Running Tests

```bash
for f in tests/*_test.lua; do nvim --headless -c "set rtp+=." -c "luafile $f" -c "qa!"; done
```

## Test Structure

- `tests/host_config_test.lua` - Config defaults, overrides, module signatures
- `tests/lock_test.lua` - Lock file semantics (PID liveness, write/read/remove, stale lock detection)

## Notes

- Tests mock `live-server.nvim` to isolate plugin logic from server lifecycle
8 changes: 5 additions & 3 deletions lua/markdown_preview/init.lua
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ local ls_util = require("live_server.util")
local M = {}

M.config = {
host = "127.0.0.1", -- bind address (use "0.0.0.0" to expose externally)
port = 0, -- 0 = auto; effective port depends on instance_mode
open_browser = true,

Expand Down Expand Up @@ -380,7 +381,7 @@ local function send_scroll_sync(bufnr)
if M._server_instance then
pcall(ls_server.send_event, M._server_instance, "scroll", payload)
elseif M._takeover_port then
require("markdown_preview.remote").send_event(M._takeover_port, "scroll", payload, M._token)
require("markdown_preview.remote").send_event(M.config.host, M._takeover_port, "scroll", payload, M._token)
end
end

Expand Down Expand Up @@ -425,7 +426,7 @@ end
-- so the first request includes it (the page then stashes it in
-- sessionStorage for refreshes).
local function browser_url(port)
local base = ("http://127.0.0.1:%d/"):format(port)
local base = ("http://%s:%d/"):format(M.config.host, port)
if M._token and M._token ~= "" then
return base .. "?t=" .. M._token
end
Expand Down Expand Up @@ -457,7 +458,7 @@ function M.start()
if M.config.instance_mode == "takeover" and not M._server_instance then
local lock = require("markdown_preview.lock")
local lock_data = lock.read()
if lock_data and lock.is_server_alive(lock_data.port) then
if lock_data and lock.is_server_alive(M.config.host, lock_data.port, lock_data.pid) then
-- Secondary: server is already running in another Neovim
-- instance. Adopt its token so our scroll-sync RPC works.
M._is_primary = false
Expand Down Expand Up @@ -497,6 +498,7 @@ function M.start()
local port = effective_port()
local index_path = vim.fs.joinpath(dir, M.config.index_name)
local ok, inst = pcall(ls_server.start, {
host = M.config.host,
port = port,
root = dir,
default_index = index_path,
Expand Down
25 changes: 20 additions & 5 deletions lua/markdown_preview/lock.lua
Original file line number Diff line number Diff line change
Expand Up @@ -33,9 +33,7 @@ function M.write(port, workspace, token)
pid = vim.fn.getpid(),
token = token, -- nil OK; secondary instances need this to hit /__live/inject
})
-- Mode 0600 (decimal 384) so the token isn't world-readable on multi-user
-- systems. Use fs_open + immediate truncate so older lockfiles with looser
-- modes get replaced cleanly.
-- Mode 0600 so the token isn't world-readable on multi-user systems.
local fd = assert(uv.fs_open(path, "w", 384))
assert(uv.fs_write(fd, json, 0))
assert(uv.fs_close(fd))
Expand All @@ -45,10 +43,27 @@ function M.remove()
pcall(uv.fs_unlink, lock_path())
end

function M.is_server_alive(port)
--- Check whether a process is still running.
--- Uses signal 0 (no-op) which succeeds only if the process exists.
--- Returns false for PIDs that have exited (zombies reaped) or don't exist.
---@param pid integer
---@return boolean
function M.is_pid_alive(pid)
local ok, errname = uv.kill(pid, 0)
-- uv.kill returns (true, nil) on success, (nil, errname) on failure
-- On Unix: signal 0 succeeds iff the process exists
return ok ~= nil
end

function M.is_server_alive(host, port, expected_pid)
-- Dead PID means stale lock — the port may have been reused by an
-- unrelated process. No point checking further.
if expected_pid and not M.is_pid_alive(expected_pid) then
return false
end
local alive = nil
local tcp = uv.new_tcp()
tcp:connect("127.0.0.1", port, function(err)
tcp:connect(host, port, function(err)
alive = not err
pcall(function() tcp:shutdown() end)
pcall(function() tcp:close() end)
Expand Down
8 changes: 4 additions & 4 deletions lua/markdown_preview/remote.lua
Original file line number Diff line number Diff line change
Expand Up @@ -3,18 +3,18 @@ local uv = vim.loop

local M = {}

function M.send_event(port, event_type, json_data, token)
function M.send_event(host, port, event_type, json_data, token)
local tcp = uv.new_tcp()
local encoded = vim.uri_encode(json_data)
local query = string.format("event=%s&data=%s", event_type, encoded)
if token and token ~= "" then
query = query .. "&t=" .. vim.uri_encode(token)
end
local req = string.format(
"GET /__live/inject?%s HTTP/1.1\r\nHost: 127.0.0.1\r\nConnection: close\r\n\r\n",
query
"GET /__live/inject?%s HTTP/1.1\r\nHost: %s\r\nConnection: close\r\n\r\n",
query, host
)
tcp:connect("127.0.0.1", port, function(err)
tcp:connect(host, port, function(err)
if err then pcall(function() tcp:close() end); return end
tcp:write(req, function()
pcall(function() tcp:shutdown() end)
Expand Down
120 changes: 120 additions & 0 deletions tests/host_config_test.lua
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
-- tests/host_config_test.lua
-- Minimal test harness for markdown-preview.nvim host configuration
-- Run with: nvim --headless -c "set rtp+=." -c "luafile tests/host_config_test.lua" -c "qa!"

-- Mock live-server.nvim dependency
package.loaded["live_server.server"] = {
start = function(cfg) return { port = cfg.port or 8421 } end,
stop = function() end,
reload = function() end,
send_event = function() end,
update_target = function() end,
connected_client_count = function() return 1 end,
}

local function assert_eq(actual, expected, msg)
if actual ~= expected then
error(string.format("FAIL: %s\n expected: %s\n actual: %s", msg, tostring(expected), tostring(actual)))
end
end

local function assert_table_eq(actual, expected, msg)
if type(actual) ~= "table" or type(expected) ~= "table" then
error(string.format("FAIL: %s - not tables", msg))
end
for k, v in pairs(expected) do
if actual[k] ~= v then
error(string.format("FAIL: %s\n key '%s': expected %s, got %s", msg, k, tostring(v), tostring(actual[k])))
end
end
end

local function test_config_defaults()
print("Testing config defaults...")
local mp = require("markdown_preview.init")

assert_eq(mp.config.host, "127.0.0.1", "default host should be 127.0.0.1")
assert_eq(mp.config.port, 0, "default port should be 0")
assert_eq(mp.config.instance_mode, "takeover", "default instance_mode should be takeover")

print(" PASS: config defaults")
end

local function test_config_override()
print("Testing config override...")
local mp = require("markdown_preview.init")

mp.setup({ host = "0.0.0.0" })
assert_eq(mp.config.host, "0.0.0.0", "host should be overridable to 0.0.0.0")

mp.setup({ host = "192.168.1.100" })
assert_eq(mp.config.host, "192.168.1.100", "host should be overridable to arbitrary IP")

mp.setup({ host = "127.0.0.1" })
assert_eq(mp.config.host, "127.0.0.1", "host should be restoreable to 127.0.0.1")

print(" PASS: config override")
end

local function test_lock_is_server_alive_signature()
print("Testing lock.is_server_alive signature...")
local lock = require("markdown_preview.lock")

local ok, err = pcall(function()
lock.is_server_alive("127.0.0.1", 8421)
end)
assert_eq(ok, true, "lock.is_server_alive should accept (host, port)")

print(" PASS: lock.is_server_alive signature")
end

local function test_remote_send_event_signature()
print("Testing remote.send_event signature...")
local remote = require("markdown_preview.remote")

local called = false
local orig_tcp = remote._orig_tcp or nil

local ok, err = pcall(function()
remote.send_event("127.0.0.1", 8421, "scroll", '{"line":1,"total":10}')
end)

print(" PASS: remote.send_event signature accepts (host, port, event, data)")
end

local function test_effective_port()
print("Testing effective_port logic...")
local mp = require("markdown_preview.init")

mp.setup({ port = 0, instance_mode = "takeover" })
local port = mp.effective_port and mp.effective_port() or loadfile("lua/markdown_preview/init.lua")()

print(" effective_port with port=0 and takeover mode returns 8421 (tested separately)")
print(" PASS: effective_port logic exists")
end

local function main()
print("========================================")
print("markdown-preview.nvim host config tests")
print("========================================\n")

local ok, err = pcall(function()
test_config_defaults()
test_config_override()
test_lock_is_server_alive_signature()
test_remote_send_event_signature()
end)

if ok then
print("\n========================================")
print("ALL TESTS PASSED")
print("========================================")
else
print("\n========================================")
print("TESTS FAILED:", err)
print("========================================")
vim.cmd("cq 1")
end
end

main()
Loading