diff --git a/.github/ISSUE_TEMPLATE/bug_report.yaml b/.github/ISSUE_TEMPLATE/bug_report.yaml index 8db3f25..e81bcf8 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').setup({ + require('lazy.nvim').setup({ spec = { { 'barrett-ruth/live-server.nvim', - lazy = false, + opts = {}, }, }, }) diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml new file mode 100644 index 0000000..83cc1c7 --- /dev/null +++ b/.github/workflows/ci.yaml @@ -0,0 +1,61 @@ +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 aca31a3..bac492d 100644 --- a/.github/workflows/quality.yaml +++ b/.github/workflows/quality.yaml @@ -24,7 +24,6 @@ jobs: - '*.lua' - '.luarc.json' - '*.toml' - - 'vim.yaml' markdown: - '*.md' - 'doc/**/*.md' @@ -36,8 +35,11 @@ jobs: if: ${{ needs.changes.outputs.lua == 'true' }} steps: - uses: actions/checkout@v4 - - uses: cachix/install-nix-action@v31 - - run: nix develop --command stylua --check . + - uses: JohnnyMorganz/stylua-action@v4 + with: + token: ${{ secrets.GITHUB_TOKEN }} + version: 2.1.0 + args: --check . lua-lint: name: Lua Lint Check @@ -46,8 +48,11 @@ jobs: if: ${{ needs.changes.outputs.lua == 'true' }} steps: - uses: actions/checkout@v4 - - uses: cachix/install-nix-action@v31 - - run: nix develop --command selene --display-style quiet . + - name: Lint with Selene + uses: NTBBloodbath/selene-action@v1.0.0 + with: + token: ${{ secrets.GITHUB_TOKEN }} + args: --display-style quiet . lua-typecheck: name: Lua Type Check @@ -70,5 +75,15 @@ jobs: if: ${{ needs.changes.outputs.markdown == 'true' }} steps: - uses: actions/checkout@v4 - - uses: cachix/install-nix-action@v31 - - run: nix develop --command prettier --check . + - 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 . diff --git a/.gitignore b/.gitignore index c7b0274..c70d0ff 100644 --- a/.gitignore +++ b/.gitignore @@ -3,9 +3,3 @@ doc/tags CLAUDE.md .claude/ node_modules/ - -.envrc -.direnv - -.repro -repro.lua diff --git a/.luarc.json b/.luarc.json index e240dd5..3ccfeda 100644 --- a/.luarc.json +++ b/.luarc.json @@ -1,9 +1,8 @@ { - "runtime.version": "LuaJIT", + "runtime.version": "Lua 5.1", "runtime.path": ["lua/?.lua", "lua/?/init.lua"], "diagnostics.globals": ["vim"], "workspace.library": ["$VIMRUNTIME/lua", "${3rd}/luv/library"], "workspace.checkThirdParty": false, - "workspace.ignoreDir": [".direnv"], "completion.callSnippet": "Replace" } diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index fdff634..10afe3f 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -14,4 +14,4 @@ repos: hooks: - id: prettier name: prettier - files: \.(md|toml|yaml|yml|sh)$ + files: \.(md|toml|ya?ml|sh)$ diff --git a/.styluaignore b/.styluaignore deleted file mode 100644 index 9b42106..0000000 --- a/.styluaignore +++ /dev/null @@ -1 +0,0 @@ -.direnv/ diff --git a/README.md b/README.md deleted file mode 100644 index 6aee784..0000000 --- a/README.md +++ /dev/null @@ -1,33 +0,0 @@ -# live-server.nvim - -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 - -Install with your package manager or via -[luarocks](https://luarocks.org/modules/barrettruth/live-server.nvim): - -``` -luarocks install live-server.nvim -``` - -## 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 8fcad84..a32bdf0 100644 --- a/doc/live-server.txt +++ b/doc/live-server.txt @@ -1,152 +1,43 @@ -*live-server.txt* Live reload for web development +live-server *live-server.txt* 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. No external dependencies required — -the server runs entirely in Lua using Neovim's built-in libuv bindings. +browser via a local development server. =============================================================================== -REQUIREMENTS *live-server-requirements* - -- Neovim >= 0.10 - -=============================================================================== -CONFIGURATION *live-server-config* - - *vim.g.live_server* -Configure via `vim.g.live_server` before the plugin loads: >lua +CONFIGURATION *live-server.config* +Configure via `vim.g.live_server` before the plugin loads: +>lua vim.g.live_server = { - port = 8080, - browser = false, + args = { '--port=5555' }, } < - *live_server.Config* -Fields: ~ +Options: ~ - {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` + {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* +COMMANDS *live-server.commands* - *:LiveServerStart* + *LiveServerStart* :LiveServerStart [dir] Start the live server. If `dir` is provided, the - server starts in the specified directory. Otherwise, - uses the directory of the current file. + server starts in the specified directory. - *: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. -=============================================================================== -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* - -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', '(live-server-toggle)') -< -Configuration with custom port and no auto-open: >lua - - vim.g.live_server = { - 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: +vim:tw=80:ft=help: diff --git a/flake.lock b/flake.lock deleted file mode 100644 index b6f667a..0000000 --- a/flake.lock +++ /dev/null @@ -1,43 +0,0 @@ -{ - "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 deleted file mode 100644 index e95338d..0000000 --- a/flake.nix +++ /dev/null @@ -1,39 +0,0 @@ -{ - 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 - { - formatter = forEachSystem (pkgs: pkgs.nixfmt-tree); - - 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 new file mode 100644 index 0000000..db335e7 --- /dev/null +++ b/lua/live-server.lua @@ -0,0 +1,148 @@ +local M = {} + +local initialized = false +local job_cache = {} + +local defaults = { + args = { '--port=5555' }, +} + +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 + +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 + +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 + +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 +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 deleted file mode 100644 index 1225a1a..0000000 --- a/lua/live-server/health.lua +++ /dev/null @@ -1,42 +0,0 @@ -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 deleted file mode 100644 index ff84dc2..0000000 --- a/lua/live-server/init.lua +++ /dev/null @@ -1,244 +0,0 @@ -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 - local parent = vim.fn.fnamemodify(cur, ':h') - if parent == cur then - return nil - end - cur = parent - 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 - 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 - ----@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 deleted file mode 100644 index 61484d9..0000000 --- a/lua/live-server/server.lua +++ /dev/null @@ -1,636 +0,0 @@ -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 385af6e..1399e94 100644 --- a/plugin/live-server.lua +++ b/plugin/live-server.lua @@ -1,13 +1,3 @@ -if vim.fn.has('nvim-0.10') == 0 then - 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 return end @@ -24,13 +14,3 @@ 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' }) diff --git a/readme.md b/readme.md new file mode 100644 index 0000000..da1f1ec --- /dev/null +++ b/readme.md @@ -0,0 +1,46 @@ +# 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). + +## Installation + +Install using your package manager of choice or via +[luarocks](https://luarocks.org/modules/barrettruth/live-server.nvim): + +``` +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`. + +## Documentation + +```vim +:help live-server.nvim +``` diff --git a/scripts/ci.sh b/scripts/ci.sh deleted file mode 100755 index 18dc321..0000000 --- a/scripts/ci.sh +++ /dev/null @@ -1,9 +0,0 @@ -#!/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 fmt -git diff --exit-code -- '*.nix' -nix develop --command lua-language-server --check . --checklevel=Warning diff --git a/selene.toml b/selene.toml index 0520794..96cf5ab 100644 --- a/selene.toml +++ b/selene.toml @@ -1,4 +1 @@ std = 'vim' - -[lints] -bad_string_escape = "allow" diff --git a/vim.toml b/vim.toml new file mode 100644 index 0000000..8bf26ea --- /dev/null +++ b/vim.toml @@ -0,0 +1,30 @@ +[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 deleted file mode 100644 index 401fce6..0000000 --- a/vim.yaml +++ /dev/null @@ -1,24 +0,0 @@ ---- -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