From d7d189a2e9f3a64079b7ac0796ba5a2ce2e8a5b7 Mon Sep 17 00:00:00 2001 From: Barrett Ruth Date: Tue, 3 Feb 2026 22:33:01 -0500 Subject: [PATCH 1/9] docs: modernize readme --- readme.md | 27 ++------------------------- 1 file changed, 2 insertions(+), 25 deletions(-) diff --git a/readme.md b/readme.md index da1f1ec..75b160f 100644 --- a/readme.md +++ b/readme.md @@ -5,7 +5,7 @@ Live reload HTML, CSS, and JavaScript files inside Neovim with the power of ## Installation -Install using your package manager of choice or via +Install with your package manager or via [luarocks](https://luarocks.org/modules/barrettruth/live-server.nvim): ``` @@ -14,30 +14,7 @@ luarocks install live-server.nvim ## Dependencies -- [live-server](https://www.npmjs.com/package/live-server) (install globally via - npm/pnpm/yarn) - -## Configuration - -Configure via `vim.g.live_server` before the plugin loads: - -```lua -vim.g.live_server = { - args = { '--port=5555' }, -} -``` - -See `:help live-server` for available options. - -## Usage - -```vim -:LiveServerStart [dir] " Start the server -:LiveServerStop [dir] " Stop the server -:LiveServerToggle [dir] " Toggle the server -``` - -The server runs by default on `http://localhost:5555`. +- [live-server](https://www.npmjs.com/package/live-server) (install globally via npm/yarn) ## Documentation From 8d6605cbac156fcf0869c29da8d9cf52ab758f20 Mon Sep 17 00:00:00 2001 From: Barrett Ruth Date: Tue, 3 Feb 2026 22:37:43 -0500 Subject: [PATCH 2/9] fix: move readme --- readme.md => README.md | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename readme.md => README.md (100%) diff --git a/readme.md b/README.md similarity index 100% rename from readme.md rename to README.md From a59bec1ae3d3c6215403c0dd756e5e50c4809b65 Mon Sep 17 00:00:00 2001 From: Barrett Ruth Date: Wed, 4 Feb 2026 01:05:57 -0500 Subject: [PATCH 3/9] feat(doc): luacats and other docs --- doc/live-server.txt | 98 ++++++++++++++++++++++++++++++++++++--------- lua/live-server.lua | 11 +++++ 2 files changed, 89 insertions(+), 20 deletions(-) diff --git a/doc/live-server.txt b/doc/live-server.txt index a32bdf0..6e49c3e 100644 --- a/doc/live-server.txt +++ b/doc/live-server.txt @@ -1,43 +1,101 @@ -live-server *live-server.txt* +*live-server.txt* Live reload for web development Author: Barrett Ruth Homepage: =============================================================================== -INTRODUCTION *live-server.nvim* +INTRODUCTION *live-server.nvim* live-server.nvim automatically reloads HTML, CSS, and JavaScript files in the browser via a local development server. =============================================================================== -CONFIGURATION *live-server.config* +REQUIREMENTS *live-server-requirements* -Configure via `vim.g.live_server` before the plugin loads: ->lua - vim.g.live_server = { - args = { '--port=5555' }, - } -< -Options: ~ - - {args} `(string[])`: Arguments passed to live-server via `vim.fn.jobstart()`. - Run `live-server --help` to see available options. - Default: { '--port=5555' } +- Neovim >= 0.9.0 +- live-server npm package (`npm install -g live-server`) =============================================================================== -COMMANDS *live-server.commands* +CONFIGURATION *live-server-config* - *LiveServerStart* + *vim.g.live_server* +Configure via `vim.g.live_server` before the plugin loads: >lua + + vim.g.live_server = { + args = { '--port=8080', '--no-browser' }, + } +< + *live_server.Config* +Fields: ~ + + {args} (`string[]`) Arguments passed to live-server via |vim.fn.jobstart()|. + Run `live-server --help` to see available options. + Default: `{ '--port=5555' }` + +=============================================================================== +COMMANDS *live-server-commands* + + *:LiveServerStart* :LiveServerStart [dir] Start the live server. If `dir` is provided, the - server starts in the specified directory. + server starts in the specified directory. Otherwise, + uses the directory of the current file. - *LiveServerStop* + *:LiveServerStop* :LiveServerStop [dir] Stop the live server. If `dir` is provided, stops the server running in the specified directory. - *LiveServerToggle* + *:LiveServerToggle* :LiveServerToggle [dir] Toggle the live server on or off. If `dir` is provided, toggles the server in that directory. +=============================================================================== +API *live-server-api* + +All functions accept an optional `dir` parameter. When omitted, the directory +of the current file is used. + +live_server.start({dir}) *live_server.start()* + Start the live server in the specified directory. + + Parameters: ~ + {dir} (`string?`) Directory to serve + +live_server.stop({dir}) *live_server.stop()* + Stop the live server running in the specified directory. + + Parameters: ~ + {dir} (`string?`) Directory of running server + +live_server.toggle({dir}) *live_server.toggle()* + Toggle the live server on or off. + + Parameters: ~ + {dir} (`string?`) Directory to toggle + +live_server.setup({user_config}) *live_server.setup()* + Deprecated: Use |vim.g.live_server| instead. + + Parameters: ~ + {user_config} (|live_server.Config|?) Configuration table + +=============================================================================== +EXAMPLES *live-server-examples* + +Start server in project root: >vim + + :LiveServerStart ~/projects/my-website +< +Lua keybinding to toggle server: >lua + + vim.keymap.set('n', 'ls', function() + require('live-server').toggle() + end) +< +Configuration with custom port and no auto-open: >lua + + vim.g.live_server = { + args = { '--port=3000', '--no-browser' }, + } +< ------------------------------------------------------------------------------- -vim:tw=80:ft=help: +vim:tw=78:ft=help:norl: diff --git a/lua/live-server.lua b/lua/live-server.lua index db335e7..36b0e97 100644 --- a/lua/live-server.lua +++ b/lua/live-server.lua @@ -1,12 +1,19 @@ +---@class live_server.Config +---@field args string[] + local M = {} local initialized = false + +---@type table local job_cache = {} +---@type live_server.Config local defaults = { args = { '--port=5555' }, } +---@type live_server.Config local config = vim.deepcopy(defaults) local function log(message, level) @@ -64,6 +71,7 @@ local function resolve_dir(dir) return vim.fn.expand(vim.fn.fnamemodify(vim.fn.expand(dir), ':p')) end +---@param dir? string function M.start(dir) if not init() then return @@ -109,6 +117,7 @@ function M.start(dir) job_cache[dir] = job_id end +---@param dir? string function M.stop(dir) dir = resolve_dir(dir) local cached_dir = find_cached_dir(dir) @@ -119,6 +128,7 @@ function M.stop(dir) end end +---@param dir? string function M.toggle(dir) dir = resolve_dir(dir) if is_running(dir) then @@ -129,6 +139,7 @@ function M.toggle(dir) end ---@deprecated Use `vim.g.live_server` instead +---@param user_config? live_server.Config function M.setup(user_config) vim.deprecate( 'require("live-server").setup()', From 26dafe2f0883022ce2c1327c113cd6b40daaf533 Mon Sep 17 00:00:00 2001 From: Barrett Ruth <62671086+barrettruth@users.noreply.github.com> Date: Sat, 7 Feb 2026 14:02:00 -0500 Subject: [PATCH 4/9] feat: add mappings (#27) ## Problem Users who want keybindings must call Lua functions directly or wrap commands in closures. There is no stable public API for key binding. ## Solution Define mappings in the plugin file and document them in a new MAPPINGS section in the vimdoc. --- doc/live-server.txt | 25 ++++++++++++++++++++++--- plugin/live-server.lua | 10 ++++++++++ 2 files changed, 32 insertions(+), 3 deletions(-) diff --git a/doc/live-server.txt b/doc/live-server.txt index 6e49c3e..ac43291 100644 --- a/doc/live-server.txt +++ b/doc/live-server.txt @@ -48,6 +48,27 @@ COMMANDS *live-server-commands* :LiveServerToggle [dir] Toggle the live server on or off. If `dir` is provided, toggles the server in that directory. +=============================================================================== +MAPPINGS *live-server-mappings* + + *(live-server-start)* +(live-server-start) Start the live server. Equivalent to + |:LiveServerStart| with no arguments. + + *(live-server-stop)* +(live-server-stop) Stop the live server. Equivalent to + |:LiveServerStop| with no arguments. + + *(live-server-toggle)* +(live-server-toggle) Toggle the live server. Equivalent to + |:LiveServerToggle| with no arguments. + +Example configuration: >lua + vim.keymap.set('n', 'ls', '(live-server-start)') + vim.keymap.set('n', 'lx', '(live-server-stop)') + vim.keymap.set('n', 'lt', '(live-server-toggle)') +< + =============================================================================== API *live-server-api* @@ -87,9 +108,7 @@ Start server in project root: >vim < Lua keybinding to toggle server: >lua - vim.keymap.set('n', 'ls', function() - require('live-server').toggle() - end) + vim.keymap.set('n', 'ls', '(live-server-toggle)') < Configuration with custom port and no auto-open: >lua diff --git a/plugin/live-server.lua b/plugin/live-server.lua index 1399e94..116935b 100644 --- a/plugin/live-server.lua +++ b/plugin/live-server.lua @@ -14,3 +14,13 @@ end, { nargs = '?' }) vim.api.nvim_create_user_command('LiveServerToggle', function(opts) require('live-server').toggle(opts.args) end, { nargs = '?' }) + +vim.keymap.set('n', '(live-server-start)', function() + require('live-server').start() +end, { desc = 'Start live server' }) +vim.keymap.set('n', '(live-server-stop)', function() + require('live-server').stop() +end, { desc = 'Stop live server' }) +vim.keymap.set('n', '(live-server-toggle)', function() + require('live-server').toggle() +end, { desc = 'Toggle live server' }) From baeb211719085eee61cdae22f106e53dcbdc4b6f Mon Sep 17 00:00:00 2001 From: Barrett Ruth <62671086+barrettruth@users.noreply.github.com> Date: Mon, 23 Feb 2026 13:08:52 -0500 Subject: [PATCH 5/9] fix: add one-time deprecation notice for Neovim < 0.10 (#31) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Problem v0.2.0 will replace the npm `live-server` dependency with a pure-Lua HTTP server that requires Neovim >= 0.10 (`vim.uv`, `vim.ui.open`). Users on older Neovim versions who auto-update will get a cryptic Lua error with no explanation or migration path. ## Solution Add a one-time notification at plugin load time for Neovim < 0.10 users. The notice tells them v0.2.0 will require Neovim >= 0.10 and shows how to pin to the `v0.1.6` tag. A marker file in `stdpath('data')` ensures the message fires exactly once, ever. The plugin continues to work normally after the notice — no functionality is removed. This is the last release supporting Neovim < 0.10 and npm `live-server`. Tag as `v0.1.6` after merge. --- plugin/live-server.lua | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/plugin/live-server.lua b/plugin/live-server.lua index 116935b..6cd10a0 100644 --- a/plugin/live-server.lua +++ b/plugin/live-server.lua @@ -1,3 +1,16 @@ +if vim.fn.has('nvim-0.10') == 0 then + local marker = vim.fn.stdpath('data') .. '/live-server-version-notice' + if vim.fn.filereadable(marker) == 0 then + vim.notify( + 'live-server.nvim v0.2.0 will require Neovim >= 0.10.\n' + .. 'To keep using this plugin, pin to the v0.1.6 tag:\n\n' + .. ' { "barrettruth/live-server.nvim", tag = "v0.1.6" }', + vim.log.levels.WARN + ) + vim.fn.writefile({}, marker) + end +end + if vim.g.loaded_live_server then return end From f42f958c243c93cb0ae5066e16f3a4af6e642308 Mon Sep 17 00:00:00 2001 From: Barrett Ruth <62671086+barrettruth@users.noreply.github.com> Date: Mon, 2 Mar 2026 23:16:35 -0500 Subject: [PATCH 6/9] feat: replace npm live-server with pure-Lua HTTP server (#29) ## Problem The plugin requires users to install Node.js and the `live-server` npm package globally. This is a heavyweight external dependency for what amounts to a simple local dev-server workflow, and it creates friction for users who don't otherwise need Node.js. ## Solution Replace the npm shell-out with a pure-Lua HTTP server built on `vim.uv` (libuv bindings), eliminating all external dependencies. The new server supports static file serving, SSE-based live reload, CSS hot-swap without full page reload, directory listings, and recursive file watching with configurable debounce. Minimum Neovim version is bumped to 0.10 for `vim.uv` and `vim.ui.open`. The old `args`-based config is automatically migrated with a deprecation warning. Closes #28. --- .github/workflows/ci.yaml | 61 ---- .github/workflows/quality.yaml | 29 +- .gitignore | 3 + .luarc.json | 2 +- .pre-commit-config.yaml | 2 +- README.md | 22 +- doc/live-server.txt | 48 ++- flake.lock | 43 +++ flake.nix | 36 ++ lua/live-server.lua | 159 --------- lua/live-server/health.lua | 42 +++ lua/live-server/init.lua | 238 ++++++++++++ lua/live-server/server.lua | 636 +++++++++++++++++++++++++++++++++ plugin/live-server.lua | 17 +- selene.toml | 3 + vim.toml | 30 -- vim.yaml | 24 ++ 17 files changed, 1097 insertions(+), 298 deletions(-) delete mode 100644 .github/workflows/ci.yaml create mode 100644 flake.lock create mode 100644 flake.nix delete mode 100644 lua/live-server.lua create mode 100644 lua/live-server/health.lua create mode 100644 lua/live-server/init.lua create mode 100644 lua/live-server/server.lua delete mode 100644 vim.toml create mode 100644 vim.yaml diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml deleted file mode 100644 index 83cc1c7..0000000 --- a/.github/workflows/ci.yaml +++ /dev/null @@ -1,61 +0,0 @@ -name: ci -on: - workflow_call: - pull_request: - branches: [main] - push: - branches: [main] - -jobs: - changes: - runs-on: ubuntu-latest - outputs: - lua: ${{ steps.changes.outputs.lua }} - steps: - - uses: actions/checkout@v4 - - uses: dorny/paths-filter@v3 - id: changes - with: - filters: | - lua: - - 'lua/**' - - 'plugin/**' - - '*.lua' - - '.luarc.json' - - 'stylua.toml' - - 'selene.toml' - - lua-format: - runs-on: ubuntu-latest - needs: changes - if: ${{ needs.changes.outputs.lua == 'true' }} - steps: - - uses: actions/checkout@v4 - - uses: JohnnyMorganz/stylua-action@v4 - with: - token: ${{ secrets.GITHUB_TOKEN }} - version: 2.1.0 - args: --check . - - lua-lint: - runs-on: ubuntu-latest - needs: changes - if: ${{ needs.changes.outputs.lua == 'true' }} - steps: - - uses: actions/checkout@v4 - - uses: NTBBloodbath/selene-action@v1.0.0 - with: - token: ${{ secrets.GITHUB_TOKEN }} - args: --display-style quiet . - - lua-typecheck: - runs-on: ubuntu-latest - needs: changes - if: ${{ needs.changes.outputs.lua == 'true' }} - steps: - - uses: actions/checkout@v4 - - uses: mrcjkb/lua-typecheck-action@v0 - with: - checklevel: Warning - directories: lua - configpath: .luarc.json diff --git a/.github/workflows/quality.yaml b/.github/workflows/quality.yaml index bac492d..aca31a3 100644 --- a/.github/workflows/quality.yaml +++ b/.github/workflows/quality.yaml @@ -24,6 +24,7 @@ jobs: - '*.lua' - '.luarc.json' - '*.toml' + - 'vim.yaml' markdown: - '*.md' - 'doc/**/*.md' @@ -35,11 +36,8 @@ jobs: if: ${{ needs.changes.outputs.lua == 'true' }} steps: - uses: actions/checkout@v4 - - uses: JohnnyMorganz/stylua-action@v4 - with: - token: ${{ secrets.GITHUB_TOKEN }} - version: 2.1.0 - args: --check . + - uses: cachix/install-nix-action@v31 + - run: nix develop --command stylua --check . lua-lint: name: Lua Lint Check @@ -48,11 +46,8 @@ jobs: if: ${{ needs.changes.outputs.lua == 'true' }} steps: - uses: actions/checkout@v4 - - name: Lint with Selene - uses: NTBBloodbath/selene-action@v1.0.0 - with: - token: ${{ secrets.GITHUB_TOKEN }} - args: --display-style quiet . + - uses: cachix/install-nix-action@v31 + - run: nix develop --command selene --display-style quiet . lua-typecheck: name: Lua Type Check @@ -75,15 +70,5 @@ jobs: if: ${{ needs.changes.outputs.markdown == 'true' }} steps: - uses: actions/checkout@v4 - - name: Setup pnpm - uses: pnpm/action-setup@v4 - with: - version: 8 - - name: Setup Node.js - uses: actions/setup-node@v4 - with: - node-version: "20" - - name: Install prettier - run: pnpm add -g prettier@3.1.0 - - name: Check markdown formatting with prettier - run: prettier --check . + - uses: cachix/install-nix-action@v31 + - run: nix develop --command prettier --check . diff --git a/.gitignore b/.gitignore index c70d0ff..2722ae6 100644 --- a/.gitignore +++ b/.gitignore @@ -3,3 +3,6 @@ doc/tags CLAUDE.md .claude/ node_modules/ + +.envrc +.direnv diff --git a/.luarc.json b/.luarc.json index 3ccfeda..19558f6 100644 --- a/.luarc.json +++ b/.luarc.json @@ -1,5 +1,5 @@ { - "runtime.version": "Lua 5.1", + "runtime.version": "LuaJIT", "runtime.path": ["lua/?.lua", "lua/?/init.lua"], "diagnostics.globals": ["vim"], "workspace.library": ["$VIMRUNTIME/lua", "${3rd}/luv/library"], diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 10afe3f..fdff634 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -14,4 +14,4 @@ repos: hooks: - id: prettier name: prettier - files: \.(md|toml|ya?ml|sh)$ + files: \.(md|toml|yaml|yml|sh)$ diff --git a/README.md b/README.md index 75b160f..6aee784 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,12 @@ # live-server.nvim -Live reload HTML, CSS, and JavaScript files inside Neovim with the power of -[live-server](https://www.npmjs.com/package/live-server). +Live reload HTML, CSS, and JavaScript files inside Neovim. No external +dependencies — the server runs entirely in Lua using Neovim's built-in libuv +bindings. + +## Dependencies + +- Neovim >= 0.10 ## Installation @@ -12,12 +17,17 @@ Install with your package manager or via luarocks install live-server.nvim ``` -## Dependencies - -- [live-server](https://www.npmjs.com/package/live-server) (install globally via npm/yarn) - ## Documentation ```vim :help live-server.nvim ``` + +## Known Limitations + +- **No recursive file watching on Linux**: libuv's `uv_fs_event` only supports + recursive directory watching on macOS and Windows. On Linux (inotify), the + `recursive` flag is silently ignored, so only files in the served root + directory trigger hot-reload. Files in subdirectories (e.g. `css/style.css`) + will not be detected. See + [libuv#1778](https://github.com/libuv/libuv/issues/1778). diff --git a/doc/live-server.txt b/doc/live-server.txt index ac43291..8fcad84 100644 --- a/doc/live-server.txt +++ b/doc/live-server.txt @@ -7,13 +7,13 @@ Homepage: INTRODUCTION *live-server.nvim* live-server.nvim automatically reloads HTML, CSS, and JavaScript files in the -browser via a local development server. +browser via a local development server. No external dependencies required — +the server runs entirely in Lua using Neovim's built-in libuv bindings. =============================================================================== REQUIREMENTS *live-server-requirements* -- Neovim >= 0.9.0 -- live-server npm package (`npm install -g live-server`) +- Neovim >= 0.10 =============================================================================== CONFIGURATION *live-server-config* @@ -22,15 +22,23 @@ CONFIGURATION *live-server-config* Configure via `vim.g.live_server` before the plugin loads: >lua vim.g.live_server = { - args = { '--port=8080', '--no-browser' }, + port = 8080, + browser = false, } < *live_server.Config* Fields: ~ - {args} (`string[]`) Arguments passed to live-server via |vim.fn.jobstart()|. - Run `live-server --help` to see available options. - Default: `{ '--port=5555' }` + {port} (`integer?`) Port for the HTTP server. Default: `5500` + {browser} (`boolean?`) Auto-open browser on start. Default: `true` + {debounce} (`integer?`) Debounce interval in ms for file changes. + Default: `120` + {ignore} (`string[]?`) Lua patterns for file paths to ignore when + watching for changes. Default: `{}` + {css_inject} (`boolean?`) Hot-swap CSS without full page reload. + Default: `true` + {debug} (`boolean?`) Log each step of the hot-reload chain via + |vim.notify()| at DEBUG level. Default: `false` =============================================================================== COMMANDS *live-server-commands* @@ -113,8 +121,32 @@ Lua keybinding to toggle server: >lua Configuration with custom port and no auto-open: >lua vim.g.live_server = { - args = { '--port=3000', '--no-browser' }, + port = 3000, + browser = false, } < +Ignore node_modules and dotfiles: >lua + + vim.g.live_server = { + ignore = { 'node_modules', '^%.' }, + } +< +=============================================================================== +KNOWN LIMITATIONS *live-server-limitations* + +No Recursive File Watching on Linux ~ + *live-server-linux-recursive* +libuv's `uv_fs_event` only supports recursive directory watching on macOS +(FSEvents) and Windows (ReadDirectoryChangesW). On Linux, the `recursive` +flag is silently accepted but ignored because inotify does not support it +natively. Only files directly in the served root directory trigger +hot-reload; files in subdirectories (e.g. `css/style.css`) will not be +detected. + +See https://github.com/libuv/libuv/issues/1778 + +Enable `debug` in |live-server-config| to verify whether `fs_event` +callbacks fire for a given file change. + ------------------------------------------------------------------------------- vim:tw=78:ft=help:norl: diff --git a/flake.lock b/flake.lock new file mode 100644 index 0000000..b6f667a --- /dev/null +++ b/flake.lock @@ -0,0 +1,43 @@ +{ + "nodes": { + "nixpkgs": { + "locked": { + "lastModified": 1771423170, + "narHash": "sha256-K7Dg9TQ0mOcAtWTO/FX/FaprtWQ8BmEXTpLIaNRhEwU=", + "owner": "NixOS", + "repo": "nixpkgs", + "rev": "bcc4a9d9533c033d806a46b37dc444f9b0da49dd", + "type": "github" + }, + "original": { + "owner": "NixOS", + "ref": "nixpkgs-unstable", + "repo": "nixpkgs", + "type": "github" + } + }, + "root": { + "inputs": { + "nixpkgs": "nixpkgs", + "systems": "systems" + } + }, + "systems": { + "locked": { + "lastModified": 1681028828, + "narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=", + "owner": "nix-systems", + "repo": "default", + "rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e", + "type": "github" + }, + "original": { + "owner": "nix-systems", + "repo": "default", + "type": "github" + } + } + }, + "root": "root", + "version": 7 +} diff --git a/flake.nix b/flake.nix new file mode 100644 index 0000000..2753286 --- /dev/null +++ b/flake.nix @@ -0,0 +1,36 @@ +{ + description = "live-server.nvim — live reload for web development in Neovim"; + + inputs = { + nixpkgs.url = "github:NixOS/nixpkgs/nixpkgs-unstable"; + systems.url = "github:nix-systems/default"; + }; + + outputs = + { + nixpkgs, + systems, + ... + }: + let + forEachSystem = f: nixpkgs.lib.genAttrs (import systems) (system: f nixpkgs.legacyPackages.${system}); + in + { + devShells = forEachSystem (pkgs: { + default = pkgs.mkShell { + packages = [ + (pkgs.luajit.withPackages ( + ps: with ps; [ + busted + nlua + ] + )) + pkgs.prettier + pkgs.stylua + pkgs.selene + pkgs.lua-language-server + ]; + }; + }); + }; +} diff --git a/lua/live-server.lua b/lua/live-server.lua deleted file mode 100644 index 36b0e97..0000000 --- a/lua/live-server.lua +++ /dev/null @@ -1,159 +0,0 @@ ----@class live_server.Config ----@field args string[] - -local M = {} - -local initialized = false - ----@type table -local job_cache = {} - ----@type live_server.Config -local defaults = { - args = { '--port=5555' }, -} - ----@type live_server.Config -local config = vim.deepcopy(defaults) - -local function log(message, level) - vim.notify(string.format('live-server.nvim: %s', message), vim.log.levels[level]) -end - -local function is_windows() - return vim.loop.os_uname().version:match('Windows') -end - -local function init() - if initialized then - return true - end - - local user_config = vim.g.live_server or {} - config = vim.tbl_deep_extend('force', defaults, user_config) - - if - not vim.fn.executable('live-server') - and not (is_windows() and vim.fn.executable('live-server.cmd')) - then - log('live-server is not executable. Ensure the npm module is properly installed', 'ERROR') - return false - end - - initialized = true - return true -end - -local function find_cached_dir(dir) - if not dir then - return nil - end - - local cur = dir - while not job_cache[cur] do - if cur == '/' or cur:match('^[A-Z]:\\$') then - return nil - end - cur = vim.fn.fnamemodify(cur, ':h') - end - return cur -end - -local function is_running(dir) - local cached_dir = find_cached_dir(dir) - return cached_dir and job_cache[cached_dir] -end - -local function resolve_dir(dir) - if not dir or dir == '' then - dir = '%:p:h' - end - return vim.fn.expand(vim.fn.fnamemodify(vim.fn.expand(dir), ':p')) -end - ----@param dir? string -function M.start(dir) - if not init() then - return - end - - dir = resolve_dir(dir) - - if is_running(dir) then - log('live-server already running', 'INFO') - return - end - - local cmd_exe = is_windows() and 'live-server.cmd' or 'live-server' - local cmd = { cmd_exe, dir } - vim.list_extend(cmd, config.args) - - local job_id = vim.fn.jobstart(cmd, { - on_stderr = function(_, data) - if not data or data[1] == '' then - return - end - log(data[1]:match('.-m(.-)\27') or data[1], 'ERROR') - end, - on_exit = function(_, exit_code) - job_cache[dir] = nil - if exit_code == 143 then - return - end - log(string.format('stopped with code %s', exit_code), 'INFO') - end, - }) - - local port = 'unknown' - for _, arg in ipairs(config.args) do - local p = arg:match('%-%-port=(%d+)') - if p then - port = p - break - end - end - - log(string.format('live-server started on 127.0.0.1:%s', port), 'INFO') - job_cache[dir] = job_id -end - ----@param dir? string -function M.stop(dir) - dir = resolve_dir(dir) - local cached_dir = find_cached_dir(dir) - if cached_dir and job_cache[cached_dir] then - vim.fn.jobstop(job_cache[cached_dir]) - job_cache[cached_dir] = nil - log('live-server stopped', 'INFO') - end -end - ----@param dir? string -function M.toggle(dir) - dir = resolve_dir(dir) - if is_running(dir) then - M.stop(dir) - else - M.start(dir) - end -end - ----@deprecated Use `vim.g.live_server` instead ----@param user_config? live_server.Config -function M.setup(user_config) - vim.deprecate( - 'require("live-server").setup()', - 'vim.g.live_server', - 'v0.1.0', - 'live-server.nvim', - false - ) - - if user_config then - vim.g.live_server = vim.tbl_deep_extend('force', vim.g.live_server or {}, user_config) - end - - init() -end - -return M diff --git a/lua/live-server/health.lua b/lua/live-server/health.lua new file mode 100644 index 0000000..1225a1a --- /dev/null +++ b/lua/live-server/health.lua @@ -0,0 +1,42 @@ +local M = {} + +function M.check() + vim.health.start('live-server.nvim') + + if vim.fn.has('nvim-0.10') == 1 then + vim.health.ok('Neovim >= 0.10') + else + vim.health.error( + 'Neovim >= 0.10 is required', + { 'Upgrade Neovim or pin live-server.nvim to v0.1.6' } + ) + end + + if vim.uv then + vim.health.ok('vim.uv is available') + else + vim.health.error('vim.uv is not available', { 'Neovim >= 0.10 provides vim.uv' }) + end + + local user_config = vim.g.live_server or {} + if user_config.args then + vim.health.warn( + 'deprecated `args` config detected', + { 'See `:h live-server-config` for the new format' } + ) + else + vim.health.ok('no deprecated config detected') + end + + if jit.os == 'Linux' then + vim.health.warn('recursive file watching is not supported on Linux', { + 'Only files in the root directory will trigger reload. See `:h live-server-linux-recursive`', + }) + end + + if vim.fn.executable('live-server') == 1 then + vim.health.info('npm `live-server` is installed but no longer required') + end +end + +return M diff --git a/lua/live-server/init.lua b/lua/live-server/init.lua new file mode 100644 index 0000000..3a8ea4b --- /dev/null +++ b/lua/live-server/init.lua @@ -0,0 +1,238 @@ +local server = require('live-server.server') + +local M = {} + +---@type boolean +local initialized = false + +---@type table +local instances = {} + +---@class live_server.Config +---@field port? integer +---@field browser? boolean +---@field debounce? integer +---@field ignore? string[] +---@field css_inject? boolean +---@field debug? boolean + +---@type live_server.Config +local defaults = { + port = 5500, + browser = true, + debounce = 120, + ignore = {}, + css_inject = true, + debug = false, +} + +---@type live_server.Config +local config = vim.deepcopy(defaults) + +---@param message string +---@param level string +local function log(message, level) + vim.notify(('live-server.nvim: %s'):format(message), vim.log.levels[level]) +end + +---@type table +local UNSUPPORTED_FLAGS = { + ['--host'] = true, + ['--open'] = true, + ['--browser'] = true, + ['--quiet'] = true, + ['--entry-file'] = true, + ['--spa'] = true, + ['--mount'] = true, + ['--proxy'] = true, + ['--htpasswd'] = true, + ['--cors'] = true, + ['--https'] = true, + ['--https-module'] = true, + ['--middleware'] = true, + ['--ignorePattern'] = true, +} + +---@param user_config table +---@return table +local function migrate_args(user_config) + if not user_config.args then + return user_config + end + + vim.deprecate( + '`vim.g.live_server.args`', + '`:h live-server-config`', + 'v0.2.0', + 'live-server.nvim', + false + ) + + local migrated = {} + for k, v in pairs(user_config) do + if k ~= 'args' then + migrated[k] = v + end + end + + for _, arg in ipairs(user_config.args) do + local port = arg:match('%-%-port=(%d+)') + if port then + migrated.port = tonumber(port) + elseif arg == '--no-browser' then + migrated.browser = false + elseif arg == '--no-css-inject' then + migrated.css_inject = false + else + local wait = arg:match('%-%-wait=(%d+)') + if wait then + migrated.debounce = tonumber(wait) + else + local ignore_val = arg:match('%-%-ignore=(.*)') + if ignore_val then + migrated.ignore = migrated.ignore or {} + for pattern in ignore_val:gmatch('[^,]+') do + migrated.ignore[#migrated.ignore + 1] = vim.trim(pattern) + end + else + local flag = arg:match('^(%-%-[%w-]+)') + if flag and UNSUPPORTED_FLAGS[flag] then + log(('flag `%s` is not supported and will be ignored'):format(arg), 'WARN') + end + end + end + end + end + + return migrated +end + +local function init() + if initialized then + return + end + + local user_config = vim.g.live_server or {} + user_config = migrate_args(user_config) + config = vim.tbl_deep_extend('force', defaults, user_config) + + vim.api.nvim_create_autocmd('VimLeavePre', { + callback = function() + for dir, inst in pairs(instances) do + server.stop(inst) + instances[dir] = nil + end + end, + }) + + initialized = true +end + +---@param dir? string +---@return string? +local function find_cached_dir(dir) + if not dir then + return nil + end + + local cur = dir + while not instances[cur] do + if cur == '/' or cur:match('^[A-Z]:\\$') then + return nil + end + cur = vim.fn.fnamemodify(cur, ':h') + end + return cur +end + +---@param dir string +---@return live_server.Instance? +local function is_running(dir) + local cached_dir = find_cached_dir(dir) + return cached_dir and instances[cached_dir] +end + +---@param dir? string +---@return string +local function resolve_dir(dir) + if not dir or dir == '' then + dir = '%:p:h' + end + return vim.fn.expand(vim.fn.fnamemodify(vim.fn.expand(dir), ':p')) +end + +---@param dir? string +function M.start(dir) + init() + + dir = resolve_dir(dir) + + if is_running(dir) then + log('already running', 'INFO') + return + end + + local root_real = vim.uv.fs_realpath(dir) + if not root_real then + log(('directory does not exist: %s'):format(dir), 'ERROR') + return + end + + local inst = server.start({ + port = config.port, + root_real = root_real, + debounce = config.debounce, + ignore = config.ignore, + css_inject = config.css_inject, + debug = config.debug, + }) + + instances[dir] = inst + log(('started on 127.0.0.1:%d'):format(config.port), 'INFO') + + if config.browser then + vim.ui.open(('http://127.0.0.1:%d/'):format(config.port)) + end +end + +---@param dir? string +function M.stop(dir) + dir = resolve_dir(dir) + local cached_dir = find_cached_dir(dir) + if cached_dir and instances[cached_dir] then + server.stop(instances[cached_dir]) + instances[cached_dir] = nil + log('stopped', 'INFO') + end +end + +---@param dir? string +function M.toggle(dir) + dir = resolve_dir(dir) + if is_running(dir) then + M.stop(dir) + else + M.start(dir) + end +end + +---@deprecated Use `vim.g.live_server` instead +---@param user_config? live_server.Config +function M.setup(user_config) + vim.deprecate( + '`require("live-server").setup()`', + '`vim.g.live_server`', + 'v0.1.0', + 'live-server.nvim', + false + ) + + if user_config then + vim.g.live_server = vim.tbl_deep_extend('force', vim.g.live_server or {}, user_config) + end + + initialized = false + init() +end + +return M diff --git a/lua/live-server/server.lua b/lua/live-server/server.lua new file mode 100644 index 0000000..61484d9 --- /dev/null +++ b/lua/live-server/server.lua @@ -0,0 +1,636 @@ +local uv = vim.uv + +---@class live_server.Instance +---@field handle uv.uv_tcp_t +---@field port integer +---@field root_real string +---@field sse_clients uv.uv_tcp_t[] +---@field debounce_timer uv.uv_timer_t +---@field fs_event uv.uv_fs_event_t? +---@field ignore_patterns string[] +---@field debounce_ms integer +---@field css_inject boolean +---@field debug boolean + +---@class live_server.StartConfig +---@field port integer +---@field root_real string +---@field debounce? integer +---@field ignore? string[] +---@field css_inject? boolean +---@field debug? boolean + +local S = {} + +local function dbg(inst, msg) + if not inst.debug then + return + end + vim.schedule(function() + vim.notify(('[live-server] %s'):format(msg), vim.log.levels.DEBUG) + end) +end + +---@type table +local MIME_TYPES = { + html = 'text/html; charset=utf-8', + htm = 'text/html; charset=utf-8', + css = 'text/css; charset=utf-8', + js = 'application/javascript; charset=utf-8', + mjs = 'application/javascript; charset=utf-8', + json = 'application/json; charset=utf-8', + xml = 'application/xml; charset=utf-8', + svg = 'image/svg+xml', + png = 'image/png', + jpg = 'image/jpeg', + jpeg = 'image/jpeg', + gif = 'image/gif', + ico = 'image/x-icon', + webp = 'image/webp', + woff = 'font/woff', + woff2 = 'font/woff2', + ttf = 'font/ttf', + otf = 'font/otf', + txt = 'text/plain; charset=utf-8', + md = 'text/plain; charset=utf-8', + wasm = 'application/wasm', +} + +---@type table +local REASON_PHRASES = { + [200] = 'OK', + [301] = 'Moved Permanently', + [400] = 'Bad Request', + [404] = 'Not Found', + [405] = 'Method Not Allowed', + [500] = 'Internal Server Error', +} + +---@type integer +local CHUNK_SIZE = 65536 + +---@type string +local CLIENT_JS = [[ +(function() { + var es = new EventSource('/__live/events'); + es.addEventListener('reload', function(e) { + var data = JSON.parse(e.data); + if (data.css) { + var links = document.querySelectorAll('link[rel="stylesheet"]'); + for (var i = 0; i < links.length; i++) { + var href = links[i].href.replace(/[?&]_lr=\d+/, ''); + links[i].href = href + (href.indexOf('?') >= 0 ? '&' : '?') + '_lr=' + Date.now(); + } + } else { + location.reload(); + } + }); +})(); +]] + +---@type string +local INJECT_TAG = '' + +---@param str string +---@return string +local function url_decode(str) + return (str:gsub('%%(%x%x)', function(hex) + return string.char(tonumber(hex, 16)) + end)) +end + +---@param path string +---@return string +local function get_mime(path) + local ext = path:match('%.([^%.]+)$') + if ext then + return MIME_TYPES[ext:lower()] or 'application/octet-stream' + end + return 'application/octet-stream' +end + +---@param path string +---@return boolean +local function is_html(path) + local ext = path:match('%.([^%.]+)$') + return ext and (ext:lower() == 'html' or ext:lower() == 'htm') +end + +---@param status integer +---@return string +local function response_line(status) + return ('HTTP/1.1 %d %s\r\n'):format(status, REASON_PHRASES[status] or 'Unknown') +end + +---@param sock uv.uv_tcp_t +---@param status integer +---@param headers table +---@param body? string +local function write_response(sock, status, headers, body) + local parts = { response_line(status) } + headers['Connection'] = 'close' + if body then + headers['Content-Length'] = tostring(#body) + end + for k, v in pairs(headers) do + parts[#parts + 1] = ('%s: %s\r\n'):format(k, v) + end + parts[#parts + 1] = '\r\n' + if body then + parts[#parts + 1] = body + end + local ok = pcall(sock.write, sock, table.concat(parts), function() + pcall(sock.shutdown, sock, function() + if not sock:is_closing() then + sock:close() + end + end) + end) + if not ok and not sock:is_closing() then + sock:close() + end +end + +---@param sock uv.uv_tcp_t +---@param status integer +local function error_response(sock, status) + local phrase = REASON_PHRASES[status] or 'Error' + local body = ([[

%d %s

]]):format(status, phrase) + write_response(sock, status, { ['Content-Type'] = 'text/html; charset=utf-8' }, body) +end + +---@param root string +---@param request_path string +---@return string? +local function resolve_path(root, request_path) + local decoded = url_decode(request_path) + local joined = ('%s/%s'):format(root, decoded) + local real = uv.fs_realpath(joined) + if not real then + return nil + end + if real:sub(1, #root) ~= root then + return nil + end + return real +end + +---@param sock uv.uv_tcp_t +---@param filepath string +local function serve_file_streaming(sock, filepath) + uv.fs_open(filepath, 'r', 438, function(err_open, fd) + if err_open or not fd then + vim.schedule(function() + error_response(sock, 500) + end) + return + end + uv.fs_fstat(fd, function(err_stat, stat) + if err_stat or not stat then + uv.fs_close(fd) + vim.schedule(function() + error_response(sock, 500) + end) + return + end + local mime = get_mime(filepath) + local size = stat.size + + if is_html(filepath) then + uv.fs_read(fd, size, 0, function(err_read, data) + uv.fs_close(fd) + if err_read or not data then + vim.schedule(function() + error_response(sock, 500) + end) + return + end + local lower = data:lower() + local inject_pos = lower:find('') + if inject_pos then + data = ('%s%s\n%s'):format( + data:sub(1, inject_pos - 1), + INJECT_TAG, + data:sub(inject_pos) + ) + else + data = ('%s\n%s'):format(data, INJECT_TAG) + end + local response = ('%s\z + Content-Type: %s\r\n\z + Content-Length: %d\r\n\z + Connection: close\r\n\z + \r\n\z + %s'):format(response_line(200), mime, #data, data) + local ok = pcall(sock.write, sock, response, function() + pcall(sock.shutdown, sock, function() + if not sock:is_closing() then + sock:close() + end + end) + end) + if not ok and not sock:is_closing() then + sock:close() + end + end) + return + end + + local header = ('%s\z + Content-Type: %s\r\n\z + Content-Length: %d\r\n\z + Connection: close\r\n\z + \r\n'):format(response_line(200), mime, size) + + local offset = 0 + ---@type fun() + local function read_chunk() + local to_read = math.min(CHUNK_SIZE, size - offset) + if to_read <= 0 then + uv.fs_close(fd) + pcall(sock.shutdown, sock, function() + if not sock:is_closing() then + sock:close() + end + end) + return + end + uv.fs_read(fd, to_read, offset, function(err_chunk, chunk) + if err_chunk or not chunk or #chunk == 0 then + uv.fs_close(fd) + if not sock:is_closing() then + sock:close() + end + return + end + offset = offset + #chunk + local wok = pcall(sock.write, sock, chunk, function() + read_chunk() + end) + if not wok then + uv.fs_close(fd) + if not sock:is_closing() then + sock:close() + end + end + end) + end + + local ok = pcall(sock.write, sock, header, function() + read_chunk() + end) + if not ok then + uv.fs_close(fd) + if not sock:is_closing() then + sock:close() + end + end + end) + end) +end + +---@param sock uv.uv_tcp_t +---@param dirpath string +---@param url_path string +---@param root string +local function serve_directory_listing(sock, dirpath, url_path, root) + uv.fs_scandir(dirpath, function(err, handle) + if err or not handle then + vim.schedule(function() + error_response(sock, 500) + end) + return + end + + ---@type string[] + local dirs = {} + ---@type string[] + local files = {} + while true do + local name, typ = uv.fs_scandir_next(handle) + if not name then + break + end + if typ == 'directory' then + dirs[#dirs + 1] = name + else + files[#files + 1] = name + end + end + table.sort(dirs) + table.sort(files) + + local prefix = url_path + if prefix:sub(-1) ~= '/' then + prefix = prefix .. '/' + end + + ---@type string[] + local entries = {} + if dirpath ~= root then + entries[#entries + 1] = '
  • ../
  • ' + end + for _, d in ipairs(dirs) do + entries[#entries + 1] = ('
  • %s/
  • '):format(prefix, d, d) + end + for _, f in ipairs(files) do + entries[#entries + 1] = ('
  • %s
  • '):format(prefix, f, f) + end + + local body = ([[ + + + +Index of %s + + + +

    Index of %s

    +
      %s
    +%s + +]]):format(prefix, prefix, table.concat(entries), INJECT_TAG) + + vim.schedule(function() + write_response(sock, 200, { ['Content-Type'] = 'text/html; charset=utf-8' }, body) + end) + end) +end + +---@param path string +---@param patterns string[] +---@return boolean +local function should_ignore(path, patterns) + for _, pattern in ipairs(patterns) do + if path:find(pattern) then + return true + end + end + return false +end + +---@param inst live_server.Instance +---@param event string +---@param payload string +local function sse_broadcast(inst, event, payload) + dbg(inst, ('sse_broadcast: %d client(s), event=%s'):format(#inst.sse_clients, event)) + local msg = ('event: %s\ndata: %s\n\n'):format(event, payload) + ---@type uv.uv_tcp_t[] + local alive = {} + for _, client in ipairs(inst.sse_clients) do + local ok = pcall(client.write, client, msg) + if ok then + alive[#alive + 1] = client + else + if not client:is_closing() then + pcall(client.close, client) + end + end + end + inst.sse_clients = alive +end + +---@param inst live_server.Instance +---@param sock uv.uv_tcp_t +---@param raw string +local function handle_request(inst, sock, raw) + local method, path = raw:match('^(%u+)%s+([^%s]+)') + if not method or not path then + error_response(sock, 400) + return + end + + if method ~= 'GET' then + error_response(sock, 405) + return + end + + path = path:gsub('%?.*$', '') + + if path == '/__live/events' then + dbg(inst, 'request: /__live/events') + local header = ('%s\z + Content-Type: text/event-stream\r\n\z + Cache-Control: no-cache\r\n\z + Connection: keep-alive\r\n\z + \r\nretry: 1000\n\n'):format(response_line(200)) + local ok = pcall(sock.write, sock, header) + if ok then + inst.sse_clients[#inst.sse_clients + 1] = sock + dbg(inst, ('sse_client connected (%d total)'):format(#inst.sse_clients)) + sock:read_start(function(read_err, data) + if read_err or not data then + for i, c in ipairs(inst.sse_clients) do + if c == sock then + table.remove(inst.sse_clients, i) + break + end + end + dbg(inst, ('sse_client disconnected (%d remaining)'):format(#inst.sse_clients)) + if not sock:is_closing() then + sock:close() + end + end + end) + else + if not sock:is_closing() then + sock:close() + end + end + return + end + + if path == '/__live/script.js' then + dbg(inst, 'request: /__live/script.js') + write_response( + sock, + 200, + { ['Content-Type'] = 'application/javascript; charset=utf-8' }, + CLIENT_JS + ) + return + end + + local resolved = resolve_path(inst.root_real, path) + if not resolved then + error_response(sock, 404) + return + end + + local stat = uv.fs_stat(resolved) + if not stat then + error_response(sock, 404) + return + end + + if stat.type == 'directory' then + if path:sub(-1) ~= '/' then + write_response(sock, 301, { ['Location'] = path .. '/' }, '') + return + end + local index = resolved .. '/index.html' + local index_stat = uv.fs_stat(index) + if index_stat and index_stat.type == 'file' then + serve_file_streaming(sock, index) + else + serve_directory_listing(sock, resolved, path, inst.root_real) + end + return + end + + serve_file_streaming(sock, resolved) +end + +---@param inst live_server.Instance +---@param err? string +local function on_connection(inst, err) + if err then + return + end + local sock = uv.new_tcp() + inst.handle:accept(sock) + + local buf = '' + sock:read_start(function(read_err, data) + if read_err or not data then + if not sock:is_closing() then + sock:close() + end + return + end + buf = buf .. data + if buf:find('\r\n\r\n') or buf:find('\n\n') then + sock:read_stop() + handle_request(inst, sock, buf) + end + end) +end + +---@param inst live_server.Instance +local function setup_file_watcher(inst) + local fs_event = uv.new_fs_event() + if not fs_event then + return + end + inst.fs_event = fs_event + + ---@type boolean + local pending_css_only = true + + ---@param watch_err? string + ---@param filename? string + local function on_change(watch_err, filename) + if watch_err then + return + end + dbg(inst, ('fs_event: %s'):format(filename or '')) + if filename and should_ignore(filename, inst.ignore_patterns) then + dbg(inst, ('fs_event ignored: %s'):format(filename)) + return + end + if filename and not filename:match('%.css$') then + pending_css_only = false + end + dbg( + inst, + ('fs_event: %s (css_only=%s)'):format(filename or '', tostring(pending_css_only)) + ) + inst.debounce_timer:stop() + inst.debounce_timer:start(inst.debounce_ms, 0, function() + local css_only = pending_css_only + pending_css_only = true + dbg(inst, ('debounce fired: css_only=%s'):format(tostring(css_only))) + vim.schedule(function() + S.reload(inst, css_only) + end) + end) + end + + local recursive = jit.os ~= 'Linux' + local ok = recursive + and pcall(fs_event.start, fs_event, inst.root_real, { recursive = true }, on_change) + if ok then + dbg(inst, ('watching: %s (recursive=true)'):format(inst.root_real)) + else + pcall(fs_event.start, fs_event, inst.root_real, {}, on_change) + dbg(inst, ('watching: %s (recursive=false)'):format(inst.root_real)) + if recursive then + return + end + end +end + +---@param cfg live_server.StartConfig +---@return live_server.Instance +function S.start(cfg) + local handle = uv.new_tcp() + handle:bind('127.0.0.1', cfg.port) + + ---@type live_server.Instance + local inst = { + handle = handle, + port = cfg.port, + root_real = cfg.root_real, + sse_clients = {}, + debounce_timer = uv.new_timer(), + fs_event = nil, + ignore_patterns = cfg.ignore or {}, + debounce_ms = cfg.debounce or 120, + css_inject = cfg.css_inject ~= false, + debug = cfg.debug or false, + } + + handle:listen(128, function(listen_err) + on_connection(inst, listen_err) + end) + + setup_file_watcher(inst) + + return inst +end + +---@param inst live_server.Instance +function S.stop(inst) + if inst.debounce_timer then + inst.debounce_timer:stop() + if not inst.debounce_timer:is_closing() then + inst.debounce_timer:close() + end + end + + if inst.fs_event then + inst.fs_event:stop() + if not inst.fs_event:is_closing() then + inst.fs_event:close() + end + end + + for _, client in ipairs(inst.sse_clients) do + if not client:is_closing() then + pcall(client.close, client) + end + end + inst.sse_clients = {} + + if inst.handle and not inst.handle:is_closing() then + inst.handle:close() + end +end + +---@param inst live_server.Instance +---@param css_only boolean +function S.reload(inst, css_only) + local use_css = css_only and inst.css_inject + local payload = use_css and '{"css":true}' or '{"css":false}' + dbg( + inst, + ('reload: css_only=%s, css_inject=%s, payload=%s'):format( + tostring(css_only), + tostring(inst.css_inject), + payload + ) + ) + sse_broadcast(inst, 'reload', payload) +end + +return S diff --git a/plugin/live-server.lua b/plugin/live-server.lua index 6cd10a0..385af6e 100644 --- a/plugin/live-server.lua +++ b/plugin/live-server.lua @@ -1,14 +1,11 @@ if vim.fn.has('nvim-0.10') == 0 then - local marker = vim.fn.stdpath('data') .. '/live-server-version-notice' - if vim.fn.filereadable(marker) == 0 then - vim.notify( - 'live-server.nvim v0.2.0 will require Neovim >= 0.10.\n' - .. 'To keep using this plugin, pin to the v0.1.6 tag:\n\n' - .. ' { "barrettruth/live-server.nvim", tag = "v0.1.6" }', - vim.log.levels.WARN - ) - vim.fn.writefile({}, marker) - end + vim.api.nvim_echo({ + { + 'live-server.nvim requires Neovim >= 0.10. ' .. 'Pin to v0.1.6 or upgrade Neovim.', + 'ErrorMsg', + }, + }, true, {}) + return end if vim.g.loaded_live_server then diff --git a/selene.toml b/selene.toml index 96cf5ab..0520794 100644 --- a/selene.toml +++ b/selene.toml @@ -1 +1,4 @@ std = 'vim' + +[lints] +bad_string_escape = "allow" diff --git a/vim.toml b/vim.toml deleted file mode 100644 index 8bf26ea..0000000 --- a/vim.toml +++ /dev/null @@ -1,30 +0,0 @@ -[selene] -base = "lua51" -name = "vim" - -[vim] -any = true - -[jit] -any = true - -[assert] -any = true - -[describe] -any = true - -[it] -any = true - -[before_each] -any = true - -[after_each] -any = true - -[spy] -any = true - -[stub] -any = true diff --git a/vim.yaml b/vim.yaml new file mode 100644 index 0000000..401fce6 --- /dev/null +++ b/vim.yaml @@ -0,0 +1,24 @@ +--- +base: lua51 +name: vim +lua_versions: + - luajit +globals: + vim: + any: true + jit: + any: true + assert: + any: true + describe: + any: true + it: + any: true + before_each: + any: true + after_each: + any: true + spy: + any: true + stub: + any: true From a4386c9f4dca845f2bbfbca2f5cc17d7d093023d Mon Sep 17 00:00:00 2001 From: Barrett Ruth Date: Wed, 4 Mar 2026 13:42:15 -0500 Subject: [PATCH 7/9] ci : scripts + format --- .luarc.json | 1 + .styluaignore | 1 + scripts/ci.sh | 7 +++++++ 3 files changed, 9 insertions(+) create mode 100644 .styluaignore create mode 100755 scripts/ci.sh diff --git a/.luarc.json b/.luarc.json index 19558f6..e240dd5 100644 --- a/.luarc.json +++ b/.luarc.json @@ -4,5 +4,6 @@ "diagnostics.globals": ["vim"], "workspace.library": ["$VIMRUNTIME/lua", "${3rd}/luv/library"], "workspace.checkThirdParty": false, + "workspace.ignoreDir": [".direnv"], "completion.callSnippet": "Replace" } diff --git a/.styluaignore b/.styluaignore new file mode 100644 index 0000000..9b42106 --- /dev/null +++ b/.styluaignore @@ -0,0 +1 @@ +.direnv/ diff --git a/scripts/ci.sh b/scripts/ci.sh new file mode 100755 index 0000000..7b5f52b --- /dev/null +++ b/scripts/ci.sh @@ -0,0 +1,7 @@ +#!/bin/sh +set -eu + +nix develop --command stylua --check . +git ls-files '*.lua' | xargs nix develop --command selene --display-style quiet +nix develop --command prettier --check . +nix develop --command lua-language-server --check . --checklevel=Warning From f18dfa0a8de96c25547061eb9f23a666ec11aaca Mon Sep 17 00:00:00 2001 From: Barrett Ruth Date: Wed, 4 Mar 2026 14:11:41 -0500 Subject: [PATCH 8/9] ci: nix --- flake.nix | 5 ++++- scripts/ci.sh | 2 ++ 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/flake.nix b/flake.nix index 2753286..e95338d 100644 --- a/flake.nix +++ b/flake.nix @@ -13,9 +13,12 @@ ... }: let - forEachSystem = f: nixpkgs.lib.genAttrs (import systems) (system: f nixpkgs.legacyPackages.${system}); + forEachSystem = + f: nixpkgs.lib.genAttrs (import systems) (system: f nixpkgs.legacyPackages.${system}); in { + formatter = forEachSystem (pkgs: pkgs.nixfmt-tree); + devShells = forEachSystem (pkgs: { default = pkgs.mkShell { packages = [ diff --git a/scripts/ci.sh b/scripts/ci.sh index 7b5f52b..18dc321 100755 --- a/scripts/ci.sh +++ b/scripts/ci.sh @@ -4,4 +4,6 @@ set -eu nix develop --command stylua --check . git ls-files '*.lua' | xargs nix develop --command selene --display-style quiet nix develop --command prettier --check . +nix fmt +git diff --exit-code -- '*.nix' nix develop --command lua-language-server --check . --checklevel=Warning From c5c69e9ec2e83c2087d2f69cca4a3f19e9f430bc Mon Sep 17 00:00:00 2001 From: Barrett Ruth <62671086+barrettruth@users.noreply.github.com> Date: Fri, 6 Mar 2026 11:58:46 -0500 Subject: [PATCH 9/9] fix: prevent infinite loop when started from non-filesystem buffer (#35) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Problem Calling \`:LiveServerStart\` from an oil.nvim buffer (or any URI-scheme buffer) caused two issues: first, \`find_cached_dir\` entered an infinite loop as \`fnamemodify(cur, ':h')\` degenerated to \`.\`, freezing Neovim and pegging the CPU; second, even after fixing the loop, the server would error out instead of doing the right thing — serving the directory being browsed. ## Solution Add a progress check to \`find_cached_dir\` so it bails if the path stops changing. Fix \`resolve_dir\` to detect URI-scheme buffer names (e.g. \`oil:///path/to/dir\`) and extract the real filesystem path from them, so \`:LiveServerStart\` correctly serves the browsed directory. Also corrects the bug report repro template (\`require('lazy')\`, \`lazy = false\`, no deprecated \`opts\`) and ignores \`repro.lua\`. Closes #34 --- .github/ISSUE_TEMPLATE/bug_report.yaml | 4 ++-- .gitignore | 3 +++ lua/live-server/init.lua | 10 ++++++++-- 3 files changed, 13 insertions(+), 4 deletions(-) diff --git a/.github/ISSUE_TEMPLATE/bug_report.yaml b/.github/ISSUE_TEMPLATE/bug_report.yaml index e81bcf8..8db3f25 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.yaml +++ b/.github/ISSUE_TEMPLATE/bug_report.yaml @@ -65,11 +65,11 @@ body: value: | vim.env.LAZY_STDPATH = '.repro' load(vim.fn.system('curl -s https://raw.githubusercontent.com/folke/lazy.nvim/main/bootstrap.lua'))() - require('lazy.nvim').setup({ + require('lazy').setup({ spec = { { 'barrett-ruth/live-server.nvim', - opts = {}, + lazy = false, }, }, }) diff --git a/.gitignore b/.gitignore index 2722ae6..c7b0274 100644 --- a/.gitignore +++ b/.gitignore @@ -6,3 +6,6 @@ node_modules/ .envrc .direnv + +.repro +repro.lua diff --git a/lua/live-server/init.lua b/lua/live-server/init.lua index 3a8ea4b..ff84dc2 100644 --- a/lua/live-server/init.lua +++ b/lua/live-server/init.lua @@ -140,7 +140,11 @@ local function find_cached_dir(dir) if cur == '/' or cur:match('^[A-Z]:\\$') then return nil end - cur = vim.fn.fnamemodify(cur, ':h') + local parent = vim.fn.fnamemodify(cur, ':h') + if parent == cur then + return nil + end + cur = parent end return cur end @@ -156,7 +160,9 @@ end ---@return string local function resolve_dir(dir) if not dir or dir == '' then - dir = '%:p:h' + local bufname = vim.api.nvim_buf_get_name(0) + local uri_path = bufname:match('^%a+://(/.*)') + dir = uri_path or '%:p:h' end return vim.fn.expand(vim.fn.fnamemodify(vim.fn.expand(dir), ':p')) end