Compare commits
10 commits
c867f5efb7
...
c5c69e9ec2
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c5c69e9ec2 | ||
| f18dfa0a8d | |||
| a4386c9f4d | |||
|
|
f42f958c24 | ||
|
|
baeb211719 | ||
|
|
26dafe2f08 | ||
| a59bec1ae3 | |||
| 8d6605cbac | |||
|
|
76199cefaa | ||
| d7d189a2e9 |
21 changed files with 1238 additions and 328 deletions
4
.github/ISSUE_TEMPLATE/bug_report.yaml
vendored
4
.github/ISSUE_TEMPLATE/bug_report.yaml
vendored
|
|
@ -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,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
|
|
|||
61
.github/workflows/ci.yaml
vendored
61
.github/workflows/ci.yaml
vendored
|
|
@ -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
|
||||
29
.github/workflows/quality.yaml
vendored
29
.github/workflows/quality.yaml
vendored
|
|
@ -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 .
|
||||
|
|
|
|||
6
.gitignore
vendored
6
.gitignore
vendored
|
|
@ -3,3 +3,9 @@ doc/tags
|
|||
CLAUDE.md
|
||||
.claude/
|
||||
node_modules/
|
||||
|
||||
.envrc
|
||||
.direnv
|
||||
|
||||
.repro
|
||||
repro.lua
|
||||
|
|
|
|||
|
|
@ -1,8 +1,9 @@
|
|||
{
|
||||
"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"],
|
||||
"workspace.checkThirdParty": false,
|
||||
"workspace.ignoreDir": [".direnv"],
|
||||
"completion.callSnippet": "Replace"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -14,4 +14,4 @@ repos:
|
|||
hooks:
|
||||
- id: prettier
|
||||
name: prettier
|
||||
files: \.(md|toml|ya?ml|sh)$
|
||||
files: \.(md|toml|yaml|yml|sh)$
|
||||
|
|
|
|||
1
.styluaignore
Normal file
1
.styluaignore
Normal file
|
|
@ -0,0 +1 @@
|
|||
.direnv/
|
||||
33
README.md
Normal file
33
README.md
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
# 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).
|
||||
|
|
@ -1,43 +1,152 @@
|
|||
live-server *live-server.txt*
|
||||
*live-server.txt* Live reload for web development
|
||||
|
||||
Author: Barrett Ruth <https://barrettruth.com>
|
||||
Homepage: <https://github.com/barrettruth/live-server.nvim>
|
||||
|
||||
===============================================================================
|
||||
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.
|
||||
browser via a local development server. No external dependencies required —
|
||||
the server runs entirely in Lua using Neovim's built-in libuv bindings.
|
||||
|
||||
===============================================================================
|
||||
CONFIGURATION *live-server.config*
|
||||
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
|
||||
|
||||
Configure via `vim.g.live_server` before the plugin loads:
|
||||
>lua
|
||||
vim.g.live_server = {
|
||||
args = { '--port=5555' },
|
||||
port = 8080,
|
||||
browser = false,
|
||||
}
|
||||
<
|
||||
Options: ~
|
||||
*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*
|
||||
COMMANDS *live-server-commands*
|
||||
|
||||
*LiveServerStart*
|
||||
*: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.
|
||||
|
||||
===============================================================================
|
||||
MAPPINGS *live-server-mappings*
|
||||
|
||||
*<Plug>(live-server-start)*
|
||||
<Plug>(live-server-start) Start the live server. Equivalent to
|
||||
|:LiveServerStart| with no arguments.
|
||||
|
||||
*<Plug>(live-server-stop)*
|
||||
<Plug>(live-server-stop) Stop the live server. Equivalent to
|
||||
|:LiveServerStop| with no arguments.
|
||||
|
||||
*<Plug>(live-server-toggle)*
|
||||
<Plug>(live-server-toggle) Toggle the live server. Equivalent to
|
||||
|:LiveServerToggle| with no arguments.
|
||||
|
||||
Example configuration: >lua
|
||||
vim.keymap.set('n', '<leader>ls', '<Plug>(live-server-start)')
|
||||
vim.keymap.set('n', '<leader>lx', '<Plug>(live-server-stop)')
|
||||
vim.keymap.set('n', '<leader>lt', '<Plug>(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', '<leader>ls', '<Plug>(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=80:ft=help:
|
||||
vim:tw=78:ft=help:norl:
|
||||
|
|
|
|||
43
flake.lock
generated
Normal file
43
flake.lock
generated
Normal file
|
|
@ -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
|
||||
}
|
||||
39
flake.nix
Normal file
39
flake.nix
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
{
|
||||
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
|
||||
];
|
||||
};
|
||||
});
|
||||
};
|
||||
}
|
||||
|
|
@ -1,148 +0,0 @@
|
|||
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
|
||||
42
lua/live-server/health.lua
Normal file
42
lua/live-server/health.lua
Normal file
|
|
@ -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
|
||||
244
lua/live-server/init.lua
Normal file
244
lua/live-server/init.lua
Normal file
|
|
@ -0,0 +1,244 @@
|
|||
local server = require('live-server.server')
|
||||
|
||||
local M = {}
|
||||
|
||||
---@type boolean
|
||||
local initialized = false
|
||||
|
||||
---@type table<string, live_server.Instance>
|
||||
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<string, boolean>
|
||||
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
|
||||
636
lua/live-server/server.lua
Normal file
636
lua/live-server/server.lua
Normal file
|
|
@ -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<string, string>
|
||||
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<integer, string>
|
||||
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 = '<script src="/__live/script.js"></script>'
|
||||
|
||||
---@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<string, string>
|
||||
---@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 = ([[<html><body><h1>%d %s</h1></body></html>]]):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('</body>')
|
||||
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] = '<li><a href="../">../</a></li>'
|
||||
end
|
||||
for _, d in ipairs(dirs) do
|
||||
entries[#entries + 1] = ('<li><a href="%s%s/">%s/</a></li>'):format(prefix, d, d)
|
||||
end
|
||||
for _, f in ipairs(files) do
|
||||
entries[#entries + 1] = ('<li><a href="%s%s">%s</a></li>'):format(prefix, f, f)
|
||||
end
|
||||
|
||||
local body = ([[
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>Index of %s</title>
|
||||
<style>body{font-family:monospace;padding:1em}a{text-decoration:none}a:hover{text-decoration:underline}li{line-height:1.6}</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>Index of %s</h1>
|
||||
<ul>%s</ul>
|
||||
%s
|
||||
</body>
|
||||
</html>]]):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 '<nil>'))
|
||||
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 '<nil>', 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
|
||||
|
|
@ -1,3 +1,13 @@
|
|||
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
|
||||
|
|
@ -14,3 +24,13 @@ end, { nargs = '?' })
|
|||
vim.api.nvim_create_user_command('LiveServerToggle', function(opts)
|
||||
require('live-server').toggle(opts.args)
|
||||
end, { nargs = '?' })
|
||||
|
||||
vim.keymap.set('n', '<Plug>(live-server-start)', function()
|
||||
require('live-server').start()
|
||||
end, { desc = 'Start live server' })
|
||||
vim.keymap.set('n', '<Plug>(live-server-stop)', function()
|
||||
require('live-server').stop()
|
||||
end, { desc = 'Stop live server' })
|
||||
vim.keymap.set('n', '<Plug>(live-server-toggle)', function()
|
||||
require('live-server').toggle()
|
||||
end, { desc = 'Toggle live server' })
|
||||
|
|
|
|||
46
readme.md
46
readme.md
|
|
@ -1,46 +0,0 @@
|
|||
# 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
|
||||
```
|
||||
9
scripts/ci.sh
Executable file
9
scripts/ci.sh
Executable file
|
|
@ -0,0 +1,9 @@
|
|||
#!/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
|
||||
|
|
@ -1 +1,4 @@
|
|||
std = 'vim'
|
||||
|
||||
[lints]
|
||||
bad_string_escape = "allow"
|
||||
|
|
|
|||
30
vim.toml
30
vim.toml
|
|
@ -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
|
||||
24
vim.yaml
Normal file
24
vim.yaml
Normal file
|
|
@ -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
|
||||
Loading…
Add table
Add a link
Reference in a new issue