diff options
| -rw-r--r-- | README.md | 186 | ||||
| -rw-r--r-- | doc/muwiki.txt | 444 | ||||
| -rw-r--r-- | doc/tags | 18 | ||||
| -rw-r--r-- | lua/muwiki/config.lua | 109 | ||||
| -rw-r--r-- | lua/muwiki/files.lua | 98 | ||||
| -rw-r--r-- | lua/muwiki/fs.lua | 85 | ||||
| -rw-r--r-- | lua/muwiki/handlers.lua | 44 | ||||
| -rw-r--r-- | lua/muwiki/health.lua | 69 | ||||
| -rw-r--r-- | lua/muwiki/init.lua | 48 | ||||
| -rw-r--r-- | lua/muwiki/links/creation.lua | 65 | ||||
| -rw-r--r-- | lua/muwiki/links/detection.lua | 58 | ||||
| -rw-r--r-- | lua/muwiki/links/navigation.lua | 20 | ||||
| -rw-r--r-- | lua/muwiki/links/open.lua | 94 | ||||
| -rw-r--r-- | lua/muwiki/links/url_handlers.lua | 71 | ||||
| -rw-r--r-- | lua/muwiki/paths.lua | 88 | ||||
| -rw-r--r-- | lua/muwiki/templates.lua | 35 |
16 files changed, 1532 insertions, 0 deletions
diff --git a/README.md b/README.md new file mode 100644 index 0000000..02f4f37 --- /dev/null +++ b/README.md @@ -0,0 +1,186 @@ +# muwiki.nvim + +A lightweight wiki plugin for Neovim using standard markdown syntax. + +## Requirements + +- Neovim v0.10+ +- Treesitter markdown parser (`:TSInstall markdown`) + +## Features + +- Standard markdown links `[text](url)` +- Multiple wiki directories +- External link handlers +- Automatic template for new page +- Link navigation with custom keymaps + +See `:help muwiki` for complete documentation and configuration options. + +## Installation + +Using [lazy.nvim](https://github.com/folke/lazy.nvim): + +```lua +{ + url = "https://git.3kgcat.fi/muwiki.nvim", + opts = { + -- Wiki directories (REQUIRED - configure at least one) + dirs = { + { name = 'default', path = '~/wiki' }, + }, + index_file = 'index.md', + use_template = false, + use_external_handlers = false, + create_missing_dirs = false, + }, + keys = { + { "<leader>ww", function() require("muwiki").open_index("default") end, desc = "Open wiki index" }, + { "<leader>oo", function() require("muwiki").open_link_with() end, ft = "markdown", desc = "Open link menu to choose external handler" }, + { "<CR>", function() require("muwiki").open_link() end, ft = "markdown", desc = "Open wiki link" }, + { "<Tab>", function() require("muwiki").next_link() end, ft = "markdown", desc = "Next wiki link" }, + { "<S-Tab>", function() require("muwiki").prev_link() end, ft = "markdown", desc = "Previous wiki link" }, + { "<CR>", function() require("muwiki").create_link() end, ft = "markdown", mode = "v", desc = "Create wiki link from selection" }, + }, +} +``` + +**Note:** The `ft = "markdown"` condition ensures keymaps are only active in markdown files. Actions automatically check if the buffer is within a configured wiki directory and do nothing if not. + +## Keymaps + +Configure keymaps using lazy.nvim's `keys` option or set them up manually: + +**Default keymaps (lazy.nvim):** +```lua +keys = { + { "<CR>", function() require("muwiki").open_link() end, ft = "markdown" }, + { "<Tab>", function() require("muwiki").next_link() end, ft = "markdown" }, + { "<S-Tab>", function() require("muwiki").prev_link() end, ft = "markdown" }, + { "<CR>", function() require("muwiki").create_link() end, ft = "markdown", mode = "v" }, +} +``` + +**Manual setup (for other plugin managers):** +```lua +-- Set up your own autocmds +vim.api.nvim_create_autocmd('BufEnter', { + pattern = '*.md', + callback = function() + vim.keymap.set('n', '<CR>', require('muwiki').open_link, { buffer = true }) + vim.keymap.set('n', '<Tab>', require('muwiki').next_link, { buffer = true }) + vim.keymap.set('n', '<S-Tab>', require('muwiki').prev_link, { buffer = true }) + vim.keymap.set('v', '<CR>', require('muwiki').create_link, { buffer = true }) + end, +}) +``` + +Note: Actions check if the buffer is within a configured wiki directory and do nothing if not. + +**Available actions:** +- `open_link()` - Open link under cursor +- `next_link()` - Jump to next markdown link +- `prev_link()` - Jump to previous markdown link +- `open_link_with()` - Open link with custom external handler +- `create_link()` - Create link from visual selection + +See `:help muwiki-commands` for complete API documentation. + +## Link Format + +```text +[Wiki page](page.md) +[Website](https://example.com) +[Relative path](file://../document.pdf) +[Absolute path](file:///tmp/image.png) +``` + +## External Handlers + +Define custom handlers for opening external URLs and files: + +```lua +external_handlers = { + -- Open HTTP/HTTPS URLs in Firefox + { + name = 'Firefox', + cmd = 'firefox', + pattern = '^https?://', + }, + -- Open videos with mpv (local files and YouTube) + { + name = 'mpv', + cmd = 'mpv', + pattern = { + '%.mp4$', + '%.mkv$', + '%.avi$', + '%.webm$', + 'youtube%.com', + 'youtu%.be', + }, + }, + -- Open images with swayimg + { + name = 'swayimg', + cmd = 'swayimg', + pattern = { + '%.png$', + '%.jpe?g$', + '%.gif$', + '%.webp$', + '%.bmp$', + }, + }, + -- Copy URL to clipboard using wl-copy + { + name = 'Copy URL', + cmd = function(url) + vim.fn.jobstart({ 'wl-copy', url }, { detach = true }) + vim.notify('URL copied to clipboard', vim.log.levels.INFO) + end, + pattern = '.*', + }, + -- Fallback for any URL (Linux) + { + name = 'xdg-open', + cmd = 'xdg-open', + pattern = '.*', + }, +} +``` + +**Handler properties:** +- `name` - Display name in the handler menu +- `cmd` - Command string or Lua function +- `pattern` - Lua pattern(s) to match URLs (string or table of strings; optional, matches all if omitted) + +**Note:** Files with extensions in `text_extensions` will always open in Neovim, bypassing external handlers. + +## Automatic Directory Creation + +Automatically create missing parent directories when creating wiki files with nested paths: + +```lua +-- Example: [Project notes](projects/myapp/notes.md) +-- Will create projects/myapp/ directory if it doesn't exist + +create_missing_dirs = 'notify', -- or 'silent', 'prompt', or false (default) +``` + +**Options:** +- `false` - Don't create directories (default) +- `true` or `'notify'` - Create directories and show notification +- `'silent'` - Create directories without notification +- `'prompt'` - Ask before creating directories + +**Security:** Directories are only created within your configured wiki root directories. + +## Recommended Plugins + +These plugins work well with muwiki.nvim: + +- [render-markdown.nvim](https://github.com/MeanderingProgrammer/render-markdown.nvim) - Improve markdown rendering in Neovim +- [outline.nvim](https://github.com/hedyhli/outline.nvim) - Navigate document structure with symbols outline + +See `:help muwiki` for full documentation. diff --git a/doc/muwiki.txt b/doc/muwiki.txt new file mode 100644 index 0000000..7183e9e --- /dev/null +++ b/doc/muwiki.txt @@ -0,0 +1,444 @@ +*muwiki.txt* A lightweight wiki plugin for Neovim + +Author: muwiki.nvim contributors +License: TBD + +CONTENTS *muwiki-contents* + +1. Introduction ........................... |muwiki-introduction| +2. Installation ........................... |muwiki-installation| +3. Configuration .......................... |muwiki-configuration| +4. Commands and API ....................... |muwiki-commands| +5. Keymaps ................................ |muwiki-keymaps| +6. Link Format ............................ |muwiki-link-format| +7. External Handlers ...................... |muwiki-external-handlers| +8. Templates .............................. |muwiki-templates| + +============================================================================== +1. INTRODUCTION *muwiki-introduction* + +muwiki.nvim is a lightweight wiki plugin for Neovim that uses standard +markdown syntax for creating and navigating wiki-style documentation. + +Features:~ +- Standard markdown links `[text](url)` - compatible with any markdown renderer +- Multiple wiki directories support +- External link handlers for custom URL/file opening +- Automatic templates for new pages +- Link navigation with custom keymaps + +Requirements:~ +- Neovim v0.10+ +- Treesitter markdown parser (install with :TSInstall markdown) + +============================================================================== +2. INSTALLATION *muwiki-installation* + +Using lazy.nvim:~ +>lua + { + url = "https://git.3kgcat.fi/muwiki.nvim", + keys = { + { "<leader>ww", function() require("muwiki").open_index("default") end, desc = "Open wiki index" }, + }, + opts = { + dirs = { + { name = 'default', path = '~/wiki' }, + }, + index_file = 'index.md', + date_fmt = '%Y-%m-%d', + use_template = false, + use_external_handlers = false, + external_handlers = { + { + name = 'xdg-open', + cmd = 'xdg-open', + pattern = '.*', + }, + }, + text_extensions = { 'md', 'txt' }, + }, + config = function(_, opts) + local muwiki = require('muwiki') + muwiki.setup(opts) + + local function setup_keymaps() + local keymap_opts = { buffer = 0, silent = true, nowait = true } + vim.keymap.set('n', '<CR>', function() muwiki.open_link() end, keymap_opts) + vim.keymap.set('n', '<Tab>', function() muwiki.next_link() end, keymap_opts) + vim.keymap.set('n', '<S-Tab>', function() muwiki.prev_link() end, keymap_opts) + end + + vim.schedule(setup_keymaps) + vim.api.nvim_create_autocmd('BufEnter', { + pattern = '*.md', + callback = setup_keymaps, + }) + end, + lazy = true, + } +< +============================================================================== +3. CONFIGURATION *muwiki-configuration* + + *muwiki.setup()* +>lua + require("muwiki").setup({ + -- Wiki directories (REQUIRED - configure at least one) + dirs = { + { name = 'default', path = '~/wiki' }, + { name = 'test', path = '~/wiki_test' }, + }, + + index_file = 'index.md', + date_fmt = '%Y-%m-%d', + use_template = false, + template = [[ +--- +title: ${title} +date: ${date} +--- +]], + + use_external_handlers = false, + -- External handler definitions (see |muwiki-external-handlers|) + external_handlers = { + { + name = 'xdg-open', + cmd = 'xdg-open', + pattern = '.*', + }, + }, + + -- File extensions to open in Neovim (bypasses external handlers) + -- Tip: Add binary extensions like 'png' here to open them as hex/text for inspection + text_extensions = { 'md', 'txt' }, + }) +< +Configuration Options:~ + + dirs List of wiki directories (required) + Each entry has `name` and `path` fields + + index_file Name of the wiki index file + Default: 'index.md' + + date_fmt Date format string for templates + Default: '%Y-%m-%d' + + use_template Enable automatic templates for new pages + Default: false + + template Template content with placeholders + See |muwiki-templates| + + use_external_handlers Enable external URL/file handlers + Default: false + + external_handlers List of external handler definitions + See |muwiki-external-handlers| + + text_extensions Extensions to open in Neovim, bypassing + external handlers. Only used when + use_external_handlers is enabled. + Even binary files like PNG can be added + to force editor opening. + Default: { 'md', 'txt' } + + create_missing_dirs Automatically create missing parent directories + when opening or creating wiki files. + Values: + false - Don't create directories (default) + true - Same as 'notify' + 'silent' - Create without notification + 'notify' - Create and show notification + 'prompt' - Ask before creating + Directories are only created within wiki root. + Default: false + +============================================================================== +4. COMMANDS AND API *muwiki-commands* + +All functions are accessed through the main module: `require("muwiki")` + + *muwiki.open_index()* +>lua + require("muwiki").open_index(name) +< +Open the index file of a wiki. + +Parameters:~ + {name} Wiki directory name as configured in `dirs` + +Example:~ +>lua + -- Open the default wiki index + require("muwiki").open_index("default") + + -- Can be bound to a key + vim.keymap.set('n', '<leader>ww', function() + require("muwiki").open_index("default") + end) +< + + *muwiki.open_link()* +>lua + require("muwiki").open_link() +< +Open the link under the cursor. Automatically detects link type: +- Wiki links: Opens in Neovim (creates new page if doesn't exist) +- Web links: Opens with xdg-open +- File links: Opens with xdg-open or in Neovim (for text_extensions) + +Note: This function uses xdg-open directly and ignores custom +external_handlers configuration. File existence is checked for file:// +links and an error is shown if the file is not found. +Use |muwiki.open_link_with()| to utilize custom handlers. + + *muwiki.next_link()* +>lua + require("muwiki").next_link() +< +Jump to the next markdown link in the current buffer. + + *muwiki.prev_link()* +>lua + require("muwiki").prev_link() +< +Jump to the previous markdown link in the current buffer. + + *muwiki.open_link_with()* +>lua + require("muwiki").open_link_with() +< +Open the link under cursor with a custom external handler. +Shows a menu if multiple handlers match the URL. +Uses custom external_handlers configuration. +Only available for web and file links. + + *muwiki.create_link()* +>lua + require("muwiki").create_link() +< +Create a markdown link from the visually selected text. +Transforms the selected text into a link format `[text](normalized_text.md)` +and opens the target file in a new buffer. + +Usage: +- Select text in visual mode (v or V) +- Call this function +- Selected text is replaced with a markdown link +- Target file is opened in a new buffer (unsaved) + +Example: +- Select: `My New Page` +- Result: `[My New Page](my_new_page.md)` + +Note: Multi-line selections are not supported. + +============================================================================== +5. KEYMAPS *muwiki-keymaps* + +muwiki.nvim provides actions that automatically check if the current buffer +is within a configured wiki directory before executing. + +Using lazy.nvim:~ +Configure keymaps in the `keys` table: > + { + 'muwiki.nvim', + keys = { + { '<CR>', function() require('muwiki').open_link() end, ft = 'markdown' }, + { '<Tab>', function() require('muwiki').next_link() end, ft = 'markdown' }, + { '<S-Tab>', function() require('muwiki').prev_link() end, ft = 'markdown' }, + { '<CR>', function() require('muwiki').create_link() end, + ft = 'markdown', mode = 'v' }, + { '<leader>oo', function() require('muwiki').open_link_with() end, + ft = 'markdown', desc = 'Open link menu to choose external handler' }, + }, + } +<The `ft = 'markdown'` condition ensures keymaps are only active in markdown +files. Actions automatically check if the buffer is within a configured wiki +directory and do nothing if not. + +Manual setup:~ +For other plugin managers, set up your own autocmds: > + vim.api.nvim_create_autocmd('BufEnter', { + pattern = '*.md', + callback = function() + local opts = { buffer = true } + vim.keymap.set('n', '<CR>', require('muwiki').open_link, opts) + vim.keymap.set('n', '<Tab>', require('muwiki').next_link, opts) + vim.keymap.set('n', '<S-Tab>', require('muwiki').prev_link, opts) + vim.keymap.set('v', '<CR>', + require('muwiki').create_link, opts) + end, + }) +< +Note: Actions automatically check if the buffer is a wiki buffer. + + *muwiki* +Actions:~ +All user-facing actions are available directly through `require('muwiki')`: + + open_link() Open link under cursor + next_link() Jump to next markdown link + prev_link() Jump to previous markdown link + open_link_with() Open link with custom external handler + create_link() Create link from visual selection + +These functions check if the buffer is a wiki buffer before executing. + +Available API Functions:~ + + open_index(name) Open wiki index file + +============================================================================== +6. LINK FORMAT *muwiki-link-format* + +muwiki.nvim uses standard markdown link syntax. + +>markdown + [Wiki page](page.md) + [Website](https://example.com) + [Relative path](file://../document.pdf) + [Absolute path](file:///tmp/image.png) +< +============================================================================== +7. EXTERNAL HANDLERS *muwiki-external-handlers* + +Define custom handlers for opening external URLs and files. + +Note: Custom handlers are only used by |muwiki.open_link_with()|. +The default <CR> mapping uses |muwiki.open_link()| which uses xdg-open +directly, ignoring custom handlers. + +Enable external handlers:~ +>lua + require("muwiki").setup({ + use_external_handlers = true, + }) +< +Handler Definition:~ +Each handler is a table with: + + name Display name in the handler menu (string) + cmd Command string or Lua function (string|function) + pattern Lua pattern(s) to match URLs (optional string or table of + strings; if omitted, matches all URLs) + +Handler Examples:~ +>lua + external_handlers = { + -- Open HTTP/HTTPS URLs in Firefox + { + name = 'Firefox', + cmd = 'firefox', + pattern = '^https?://', + }, + -- Open videos with mpv using multiple patterns (table) + { + name = 'mpv', + cmd = 'mpv', + pattern = { + '%.mp4$', + '%.mkv$', + '%.avi$', + '%.webm$', + 'youtube%.com', + 'youtu%.be', + }, + }, + -- Open images with swayimg using multiple patterns + { + name = 'swayimg', + cmd = 'swayimg', + pattern = { + '%.png$', + '%.jpe?g$', + '%.gif$', + '%.webp$', + '%.bmp$', + }, + }, + -- Copy URL to clipboard using Lua function + { + name = 'Copy URL', + cmd = function(url) + vim.fn.jobstart({ 'wl-copy', url }, { detach = true }) + vim.notify('URL copied to clipboard', vim.log.levels.INFO) + end, + pattern = '.*', + }, + -- Fallback for any URL + { + name = 'xdg-open', + cmd = 'xdg-open', + pattern = '.*', + }, + } +< +Interaction with text_extensions:~ +The |muwiki-configuration| option `text_extensions` overrides external +handlers. Files with extensions listed in `text_extensions` will always +open in Neovim, even when `use_external_handlers` is enabled. This is +useful for forcing certain file types into the editor (e.g., adding +'png' to edit images as text/hex). + +Example: Open PNG files in Neovim (as text/hex) instead of external viewer:~ +>lua + text_extensions = { 'md', 'txt', 'png' } +< + +============================================================================== +8. TEMPLATES *muwiki-templates* + +Automatic templates can be applied when creating new wiki pages. + +Enable templates:~ +>lua + require("muwiki").setup({ + use_template = true, + }) +< +Template Placeholders:~ + + ${title} Page title (derived from filename) + ${date} Current date (formatted with `date_fmt`) + +Default Template:~ +>lua + template = [[ +--- +title: ${title} +date: ${date} +--- +]] +< +Custom Template Example:~ +>lua + require("muwiki").setup({ + template = [[ +--- +title: ${title} +date: ${date} +--- + +# ${title} + +]], + }) +< +============================================================================== +HEALTH CHECKING *muwiki-health* + +Run health check with: +>:checkhealth muwiki +< +The health check verifies: +- Wiki directories are configured +- Wiki directories exist and are accessible +- Treesitter markdown parser is installed (required for link detection) +- External handler commands are available (if configured) + +============================================================================== +vim:tw=78:ts=8:ft=help:norl: diff --git a/doc/tags b/doc/tags new file mode 100644 index 0000000..f52890d --- /dev/null +++ b/doc/tags @@ -0,0 +1,18 @@ +muwiki-commands muwiki.txt /*muwiki-commands* +muwiki-configuration muwiki.txt /*muwiki-configuration* +muwiki-contents muwiki.txt /*muwiki-contents* +muwiki-external-handlers muwiki.txt /*muwiki-external-handlers* +muwiki-health muwiki.txt /*muwiki-health* +muwiki-installation muwiki.txt /*muwiki-installation* +muwiki-introduction muwiki.txt /*muwiki-introduction* +muwiki-keymaps muwiki.txt /*muwiki-keymaps* +muwiki-link-format muwiki.txt /*muwiki-link-format* +muwiki-templates muwiki.txt /*muwiki-templates* +muwiki.next_link() muwiki.txt /*muwiki.next_link()* +muwiki.open_index() muwiki.txt /*muwiki.open_index()* +muwiki.open_link() muwiki.txt /*muwiki.open_link()* +muwiki.open_link_with() muwiki.txt /*muwiki.open_link_with()* +muwiki.prev_link() muwiki.txt /*muwiki.prev_link()* +muwiki.setup() muwiki.txt /*muwiki.setup()* +muwiki.create_link() muwiki.txt /*muwiki.create_link()* +muwiki.txt muwiki.txt /*muwiki.txt* diff --git a/lua/muwiki/config.lua b/lua/muwiki/config.lua new file mode 100644 index 0000000..d9b6661 --- /dev/null +++ b/lua/muwiki/config.lua @@ -0,0 +1,109 @@ +local fs = require('muwiki.fs') + +local M = {} + +M.options = { + dirs = nil, + index_file = 'index.md', + date_fmt = '%Y-%m-%d', + use_template = false, + template = [[ +title: ${title} +date: ${date} +]], + text_extensions = { 'md', 'txt' }, + use_external_handlers = false, + external_handlers = { + { + name = 'xdg-open', + cmd = 'xdg-open', + pattern = '.*', + }, + }, + create_missing_dirs = false, +} + +function M.setup(opts) + opts = opts or {} + + if opts.dirs then + M.options.dirs = {} + for _, dir in ipairs(opts.dirs) do + local path = vim.fs.normalize(dir.path) + if not vim.endswith(path, '/') then + path = path .. '/' + end + table.insert(M.options.dirs, { name = dir.name, path = path }) + end + end + + for key, value in pairs(opts) do + if key == 'dirs' then + -- handled above + elseif M.options[key] ~= nil then + M.options[key] = value + end + end + + for _, dir in ipairs(M.options.dirs or {}) do + if not fs.dir_exists(dir.path) then + vim.notify('Wiki directory not found: ' .. dir.path, vim.log.levels.WARN) + end + end +end + +function M.get_wiki_path(name) + if not M.options.dirs or #M.options.dirs == 0 then + vim.notify('MuWiki: No dirs configured. See :help muwiki-configuration', vim.log.levels.ERROR) + return nil + end + + if name then + for _, dir in ipairs(M.options.dirs) do + if dir.name == name then + return dir.path + end + end + vim.notify(string.format('Wiki "%s" not found, using default', name), vim.log.levels.WARN) + end + + return M.options.dirs[1].path +end + +function M.get_wiki_root_for_file(filepath) + if not M.options.dirs or #M.options.dirs == 0 then + return nil + end + + local normalized_path = vim.fs.normalize(filepath) + + for _, dir in ipairs(M.options.dirs) do + if vim.startswith(normalized_path, dir.path) then + return dir.path + end + end + + return nil +end + +function M.get_wiki_root_for_buffer(bufnr) + bufnr = bufnr or 0 + local ok, cached = pcall(vim.api.nvim_buf_get_var, bufnr, 'muwiki_root') + if ok then + return cached + end + + local root = M.get_wiki_root_for_file(vim.api.nvim_buf_get_name(bufnr)) + if root then + pcall(vim.api.nvim_buf_set_var, bufnr, 'muwiki_root', root) + end + return root +end + +function M.is_wiki_buffer(bufnr) + bufnr = bufnr or 0 + return vim.bo[bufnr].filetype == 'markdown' + and M.get_wiki_root_for_buffer(bufnr) ~= nil +end + +return M diff --git a/lua/muwiki/files.lua b/lua/muwiki/files.lua new file mode 100644 index 0000000..851feb4 --- /dev/null +++ b/lua/muwiki/files.lua @@ -0,0 +1,98 @@ +local config = require('muwiki.config') +local paths = require('muwiki.paths') +local fs = require('muwiki.fs') + +local M = {} + +function M.open_in_buffer(filepath) + local bufnr = vim.fn.bufnr(filepath, true) + vim.api.nvim_win_set_buf(0, bufnr) +end + +function M.open_external(url) + if type(url) ~= 'string' then + vim.notify('Invalid URL type', vim.log.levels.ERROR) + return false + end + + local valid, err = paths.validate_url_scheme(url) + if not valid then + vim.notify(err, vim.log.levels.ERROR) + return false + end + + vim.system({ 'xdg-open', url }, { detach = true }) + return true +end + +function M.open_index(name) + local wiki_path = config.get_wiki_path(name) + if not wiki_path then + return + end + + local index_path = vim.fs.joinpath(wiki_path, config.options.index_file) + M.open_in_buffer(index_path) +end + +function M.resolve(filepath, wiki_root) + if not wiki_root then + error('wiki_root parameter is required for secure path resolution') + end + + local path = paths.strip_file_protocol(filepath) + + local resolved = paths.resolve(path, wiki_root) + + return paths.validate_within_wiki(resolved, wiki_root, filepath) +end + +function M.is_text_file(ext) + local ext_lower = ext:lower() + for _, text_ext in ipairs(config.options.text_extensions) do + if ext_lower == text_ext then + return true + end + end + return false +end + +function M.normalize_filename(text) + local normalized = text:lower() + normalized = normalized:gsub('%s+', '_') + normalized = normalized:gsub('[^%w_%-%.]', '') + return normalized +end + +function M.open_wiki_file(filepath) + local templates = require('muwiki.templates') + + local exists = fs.file_exists(filepath) + + if not exists and config.options.create_missing_dirs then + local wiki_root = config.get_wiki_root_for_file(vim.api.nvim_buf_get_name(0)) + if wiki_root then + local mode = config.options.create_missing_dirs + + if mode == 'prompt' then + fs.ensure_parent_dirs(filepath, wiki_root, mode, function(success) + if success then + M.open_in_buffer(filepath) + templates.init_file(vim.api.nvim_get_current_buf(), filepath) + end + end) + return + else + local success = fs.ensure_parent_dirs(filepath, wiki_root, mode) + if not success then + return + end + end + end + end + + M.open_in_buffer(filepath) + templates.init_file(vim.api.nvim_get_current_buf(), filepath) +end + +return M diff --git a/lua/muwiki/fs.lua b/lua/muwiki/fs.lua new file mode 100644 index 0000000..6d53c0a --- /dev/null +++ b/lua/muwiki/fs.lua @@ -0,0 +1,85 @@ +local paths = require('muwiki.paths') + +local M = {} + +function M.file_exists(path) + local stat = vim.uv.fs_stat(path) + return stat and stat.type == 'file' +end + +function M.dir_exists(path) + local stat = vim.uv.fs_stat(path) + return stat and stat.type == 'directory' +end + +function M.create_dir_safely(dirpath, wiki_root) + if not paths.is_within_wiki(dirpath, wiki_root) then + return false, 'Directory is outside wiki root' + end + + if M.dir_exists(dirpath) then + return true, nil + end + + if vim.fn.mkdir(dirpath, 'p') ~= 1 then + return false, 'Failed to create directory' + end + + if not paths.is_within_wiki(dirpath, wiki_root) then + vim.fn.delete(dirpath, 'd') + return false, 'Symlink attack detected' + end + + return true, nil +end + +function M.ensure_parent_dirs(filepath, wiki_root, mode, callback) + local dirpath = vim.fs.dirname(filepath) + + if M.dir_exists(dirpath) then + if callback then + callback(true) + end + return true + end + + local function do_create() + local success, err = M.create_dir_safely(dirpath, wiki_root) + if not success then + vim.notify(string.format('Cannot create directory: %s', err), vim.log.levels.ERROR) + if callback then + callback(false) + end + return false + end + + if mode == 'notify' then + vim.notify(string.format('Created directory: %s', dirpath), vim.log.levels.INFO) + end + + if callback then + callback(true) + end + return true + end + + if mode == 'prompt' then + vim.ui.select({ 'Yes', 'No' }, { + prompt = string.format('Directory does not exist. Create %s?', dirpath), + }, function(choice) + if choice == 'Yes' then + do_create() + else + vim.notify('Directory creation cancelled', vim.log.levels.INFO) + if callback then + callback(false) + end + end + end) + return nil + end + + return do_create() +end + +return M diff --git a/lua/muwiki/handlers.lua b/lua/muwiki/handlers.lua new file mode 100644 index 0000000..4b320c3 --- /dev/null +++ b/lua/muwiki/handlers.lua @@ -0,0 +1,44 @@ +local config = require('muwiki.config') + + +local M = {} + + +function M.execute(handler, url) + if type(handler.cmd) == 'function' then + handler.cmd(url) + else + vim.system({ handler.cmd, url }, { detach = true }) + end +end + +function M.matches(handler, url) + local pattern = handler.pattern + + if pattern == nil then + return true + end + + if type(pattern) == 'string' then + return url:match(pattern) ~= nil + end + + for _, p in ipairs(pattern) do + if url:match(p) then + return true + end + end + return false +end + +function M.get_matching(url) + local matching = {} + for _, handler in ipairs(config.options.external_handlers) do + if M.matches(handler, url) then + table.insert(matching, handler) + end + end + return matching +end + +return M diff --git a/lua/muwiki/health.lua b/lua/muwiki/health.lua new file mode 100644 index 0000000..a118cc8 --- /dev/null +++ b/lua/muwiki/health.lua @@ -0,0 +1,69 @@ + +local fs = require('muwiki.fs') + +local M = {} + +local function command_exists(cmd) + if type(cmd) == 'function' then + return true + end + return vim.fn.executable(cmd) == 1 +end + +M.check = function() + vim.health.start('Wiki Setup') + + local ok, config = pcall(require, 'muwiki.config') + if not ok then + vim.health.error('Failed to load muwiki.config module') + return + end + + local cfg = config.options + + if not cfg.dirs or #cfg.dirs == 0 then + vim.health.error('No wiki directories configured') + vim.health.info('Add to your config: dirs = {{name = "default", path = "~/wiki"}}') + else + for _, dir in ipairs(cfg.dirs) do + if fs.dir_exists(dir.path) then + vim.health.ok(string.format("Wiki '%s': %s", dir.name, dir.path)) + else + vim.health.warn(string.format("Wiki '%s': %s (not found)", dir.name, dir.path)) + end + end + end + + vim.health.start('External Handlers') + + if cfg.use_external_handlers then + vim.health.ok('External handlers are enabled') + + for _, handler in ipairs(cfg.external_handlers) do + if type(handler.cmd) == 'function' then + vim.health.ok(string.format("Handler '%s': <Lua function>", handler.name)) + elseif command_exists(handler.cmd) then + vim.health.ok(string.format("Handler '%s': %s", handler.name, handler.cmd)) + else + vim.health.error( + string.format("Handler '%s': command not found (%s)", handler.name, handler.cmd) + ) + end + end + else + vim.health.info('External handlers are disabled') + end + + vim.health.start('Treesitter (Required)') + + local has_ts_parser = pcall(vim.treesitter.get_parser, 0, 'markdown') + if has_ts_parser then + vim.health.ok('Treesitter markdown parser is installed') + else + vim.health.error('Treesitter markdown parser not installed') + vim.health.info('Install with: :TSInstall markdown') + vim.health.info('Link detection requires treesitter') + end +end + +return M diff --git a/lua/muwiki/init.lua b/lua/muwiki/init.lua new file mode 100644 index 0000000..6dc088f --- /dev/null +++ b/lua/muwiki/init.lua @@ -0,0 +1,48 @@ + +local M = {} + +local config = require('muwiki.config') + +local function wiki_buffer() + return config.is_wiki_buffer(0) +end + +M.setup = function(opts) + config.setup(opts) +end + +function M.open_link() + if wiki_buffer() then + require('muwiki.links.open').open_link() + end +end + +function M.next_link() + if wiki_buffer() then + require('muwiki.links.navigation').next_link() + end +end + +function M.prev_link() + if wiki_buffer() then + require('muwiki.links.navigation').prev_link() + end +end + +function M.open_link_with() + if wiki_buffer() then + require('muwiki.links.open').open_link_with() + end +end + +function M.create_link() + if wiki_buffer() then + require('muwiki.links.creation').create_link() + end +end + +function M.open_index(name) + require('muwiki.files').open_index(name) +end + +return M diff --git a/lua/muwiki/links/creation.lua b/lua/muwiki/links/creation.lua new file mode 100644 index 0000000..0b31b10 --- /dev/null +++ b/lua/muwiki/links/creation.lua @@ -0,0 +1,65 @@ +local M = {} + +function M.create_link() + local files = require('muwiki.files') + local config = require('muwiki.config') + + local mode = vim.fn.mode() + if mode ~= 'v' and mode ~= 'V' then + vim.notify('Must be in visual mode to create a link', vim.log.levels.WARN) + return + end + + local start_pos = vim.fn.getpos('v') + local end_pos = vim.fn.getpos('.') + local region = vim.fn.getregion(start_pos, end_pos, { type = mode }) + + if not region or #region == 0 then + vim.notify('No text selected', vim.log.levels.WARN) + return + end + + if #region > 1 then + vim.notify('Multi-line selection not supported', vim.log.levels.WARN) + return + end + + local selected_text = region[1] + local normalized = files.normalize_filename(selected_text) + local link_target = normalized .. '.md' + local link_text = string.format('[%s](%s)', selected_text, link_target) + + local start_row = start_pos[2] + local start_col = start_pos[3] + local end_row = end_pos[2] + local end_col = end_pos[3] + + if start_row > end_row or (start_row == end_row and start_col > end_col) then + start_row, end_row = end_row, start_row + start_col, end_col = end_col, start_col + end + + start_row = start_row - 1 + start_col = start_col - 1 + end_row = end_row - 1 + + if mode == 'V' then + start_col = 0 + local line = vim.api.nvim_buf_get_lines(0, end_row, end_row + 1, false)[1] + end_col = #line + end + + vim.api.nvim_buf_set_text(0, start_row, start_col, end_row, end_col, { link_text }) + vim.api.nvim_feedkeys(vim.api.nvim_replace_termcodes('<Esc>', true, false, true), 'n', false) + + local wiki_root = config.get_wiki_root_for_file(vim.api.nvim_buf_get_name(0)) + if not wiki_root then + vim.notify('Not in a wiki buffer', vim.log.levels.ERROR) + return + end + + local target_path = files.resolve(link_target, wiki_root) + files.open_wiki_file(target_path) +end + +return M diff --git a/lua/muwiki/links/detection.lua b/lua/muwiki/links/detection.lua new file mode 100644 index 0000000..356e0ba --- /dev/null +++ b/lua/muwiki/links/detection.lua @@ -0,0 +1,58 @@ +local M = {} + +local function get_link_type(target) + if target:match('^https?://') then + return 'web' + end + if target:match('^file://') then + return 'file' + end + return 'wiki' +end + +function M.get_link() + local cursor = vim.api.nvim_win_get_cursor(0) + + local ok, node = pcall(vim.treesitter.get_node, { + bufnr = 0, + pos = { cursor[1] - 1, cursor[2] }, + lang = 'markdown', + ignore_injections = false, + }) + + if not ok or not node then + return nil + end + + local link_node = node + while link_node and link_node:type() ~= 'inline_link' do + link_node = link_node:parent() + end + + if not link_node then + return nil + end + + local text_node, dest_node + for child in link_node:iter_children() do + local t = child:type() + if t == 'link_text' then + text_node = child + elseif t == 'link_destination' then + dest_node = child + end + end + + if not text_node or not dest_node then + return nil + end + + local destination = vim.treesitter.get_node_text(dest_node, 0) + return { + text = vim.treesitter.get_node_text(text_node, 0), + target = destination, + type = get_link_type(destination), + } +end + +return M diff --git a/lua/muwiki/links/navigation.lua b/lua/muwiki/links/navigation.lua new file mode 100644 index 0000000..42b6654 --- /dev/null +++ b/lua/muwiki/links/navigation.lua @@ -0,0 +1,20 @@ +local M = {} + +local function jump_link(direction) + local flags = direction == 'next' and 'w' or 'bw' + local msg = direction == 'next' and 'No more links' or 'No previous links' + + if vim.fn.search('\\[.\\{-}\\]', flags) == 0 then + vim.notify(msg, vim.log.levels.INFO) + end +end + +function M.next_link() + jump_link('next') +end + +function M.prev_link() + jump_link('prev') +end + +return M diff --git a/lua/muwiki/links/open.lua b/lua/muwiki/links/open.lua new file mode 100644 index 0000000..5afec53 --- /dev/null +++ b/lua/muwiki/links/open.lua @@ -0,0 +1,94 @@ +local config = require('muwiki.config') +local links = require('muwiki.links.detection') +local files = require('muwiki.files') +local handlers = require('muwiki.handlers') +local url_handlers = require('muwiki.links.url_handlers') + +local M = {} + +local function get_wiki_root_or_notify() + local wiki_root = config.get_wiki_root_for_buffer(0) + if not wiki_root then + vim.notify('Not in a wiki buffer', vim.log.levels.ERROR) + return nil + end + return wiki_root +end + +function M.open_link() + local link = links.get_link() + if not link then + vim.notify('No link found under cursor', vim.log.levels.WARN) + return + end + + if link.type == 'web' then + url_handlers.handle_web_link(link.target) + return + end + + local wiki_root = get_wiki_root_or_notify() + if not wiki_root then + return + end + + if link.type == 'file' then + if vim.startswith(link.target, 'file://') then + url_handlers.handle_file_url(link.target) + else + url_handlers.handle_file_link(link.target, wiki_root) + end + return + end + + url_handlers.handle_wiki_link(link.target, wiki_root) +end + +function M.open_link_with() + local link = links.get_link() + if not link then + vim.notify('No link found under cursor', vim.log.levels.WARN) + return + end + + if link.type == 'wiki' then + vim.notify('Menu not available for wiki links', vim.log.levels.WARN) + return + end + + local url = link.target + if link.type == 'file' then + local wiki_root = get_wiki_root_or_notify() + if not wiki_root then + return + end + url = files.resolve(url, wiki_root) + end + + local matching_handlers = handlers.get_matching(url) + + if #matching_handlers == 0 then + vim.notify('No handlers available for this URL', vim.log.levels.WARN) + return + end + + if #matching_handlers == 1 then + handlers.execute(matching_handlers[1], url) + return + end + + local handler_names = {} + for _, handler in ipairs(matching_handlers) do + table.insert(handler_names, handler.name) + end + + vim.ui.select(handler_names, { + prompt = 'Open with:', + }, function(choice, idx) + if choice and idx then + handlers.execute(matching_handlers[idx], url) + end + end) +end + +return M diff --git a/lua/muwiki/links/url_handlers.lua b/lua/muwiki/links/url_handlers.lua new file mode 100644 index 0000000..f84715a --- /dev/null +++ b/lua/muwiki/links/url_handlers.lua @@ -0,0 +1,71 @@ +local config = require('muwiki.config') +local paths = require('muwiki.paths') +local files = require('muwiki.files') +local fs = require('muwiki.fs') + +local M = {} + +function M.handle_web_link(url) + if not config.options.use_external_handlers then + return + end + files.open_external(url) +end + +local function resolve_file_url(url) + local path = paths.strip_file_protocol(url) + local resolved_path + + local path_type = paths.get_path_type(path) + + if path_type == 'absolute' then + resolved_path = path + elseif path_type == 'home' then + resolved_path = vim.fs.normalize(path) + else + local current_dir = vim.fs.dirname(vim.api.nvim_buf_get_name(0)) + resolved_path = paths.resolve_relative(path, current_dir) + end + + return 'file://' .. vim.fs.normalize(resolved_path) +end + +function M.handle_file_url(url) + if not config.options.use_external_handlers then + return + end + + local absolute_url = resolve_file_url(url) + files.open_external(absolute_url) +end + +function M.handle_file_link(target, wiki_root) + local ok, file_path = pcall(files.resolve, target, wiki_root) + if not ok then + vim.notify(string.format('Cannot resolve path: %s', target), vim.log.levels.ERROR) + return + end + + if not fs.file_exists(file_path) then + vim.notify(string.format('File not found: %s', file_path), vim.log.levels.ERROR) + return + end + + local ext = file_path:match('%.([^%.]+)$') or '' + if not files.is_text_file(ext) then + if not config.options.use_external_handlers then + return + end + files.open_external(file_path) + return + end + + files.open_in_buffer(file_path) +end + +function M.handle_wiki_link(target, wiki_root) + local file_path = files.resolve(target, wiki_root) + files.open_wiki_file(file_path) +end + +return M diff --git a/lua/muwiki/paths.lua b/lua/muwiki/paths.lua new file mode 100644 index 0000000..aa07776 --- /dev/null +++ b/lua/muwiki/paths.lua @@ -0,0 +1,88 @@ + +local M = {} + +function M.get_path_type(path) + if path:sub(1, 1) == '/' then + return 'absolute' + elseif path:sub(1, 1) == '~' then + return 'home' + else + return 'relative' + end +end + +function M.resolve_relative(path, base) + local result + + if vim.startswith(path, './') then + result = vim.fs.joinpath(base, path:sub(3)) + elseif vim.startswith(path, '../') then + local current_base = base + local remaining = path + + while vim.startswith(remaining, '../') do + current_base = vim.fs.dirname(current_base) + remaining = remaining:sub(4) + end + + result = vim.fs.joinpath(current_base, remaining) + else + result = vim.fs.joinpath(base, path) + end + + return vim.fs.normalize(result) +end + +function M.resolve(filepath, current_file) + local path_type = M.get_path_type(filepath) + + if path_type == 'absolute' then + return vim.fs.normalize(filepath) + elseif path_type == 'home' then + return vim.fs.normalize(filepath) + else + local base = current_file and vim.fs.dirname(current_file) + or vim.fs.dirname(vim.api.nvim_buf_get_name(0)) + return M.resolve_relative(filepath, base) + end +end + +function M.strip_file_protocol(url) + return url:gsub('^file://', '') +end + +function M.is_within_wiki(filepath, wiki_root) + local real_path = vim.fn.resolve(filepath) + local real_root = vim.fn.resolve(wiki_root) + + local normalized_path = vim.fs.normalize(real_path) + local normalized_root = vim.fs.normalize(real_root) + + return vim.startswith(normalized_path, normalized_root) +end + +function M.validate_within_wiki(resolved_path, wiki_root, original_path) + if not M.is_within_wiki(resolved_path, wiki_root) then + vim.notify( + string.format('Warning: Resolved path outside wiki root: %s', original_path), + vim.log.levels.WARN + ) + end + return resolved_path +end + +local ALLOWED_SCHEMES = { + http = true, + https = true, + file = true, +} + +function M.validate_url_scheme(url) + local scheme = url:match('^([a-zA-Z]+)://') + if scheme and not ALLOWED_SCHEMES[scheme:lower()] then + return false, string.format('URL scheme not allowed: %s', scheme) + end + return true, nil +end + +return M diff --git a/lua/muwiki/templates.lua b/lua/muwiki/templates.lua new file mode 100644 index 0000000..c8dcd13 --- /dev/null +++ b/lua/muwiki/templates.lua @@ -0,0 +1,35 @@ +local config = require('muwiki.config') +local fs = require('muwiki.fs') + +local M = {} + +local function process_template(template, title) + local date_fmt = config.options.date_fmt or '%Y-%m-%d' + local date = os.date(date_fmt) + local result = template:gsub('${title}', title):gsub('${date}', date) + + if not result:match('\n$') then + result = result .. '\n' + end + + return result +end + +function M.init_file(bufnr, filepath) + if fs.file_exists(filepath) then + return + end + + local filename = vim.fs.basename(filepath) + + if config.options.use_template then + local title = filename:gsub('%.md$', ''):gsub('_', ' ') + local content = process_template(config.options.template, title) + local lines = vim.split(content, '\n', { plain = true }) + vim.api.nvim_buf_set_lines(bufnr, 0, -1, false, lines) + end + + vim.notify(string.format('%s (unsaved)', filename), vim.log.levels.INFO) +end + +return M |
