aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authormoxie <moxie@3kgcat.fi>2026-03-13 09:58:53 +0200
committermoxie <moxie@3kgcat.fi>2026-03-13 09:58:53 +0200
commitbc2944651f4dabc68d7f34c796400d80ba132016 (patch)
tree3655716442a24ccb758983f0ddc89222916f64c1
chore: init
-rw-r--r--README.md186
-rw-r--r--doc/muwiki.txt444
-rw-r--r--doc/tags18
-rw-r--r--lua/muwiki/config.lua109
-rw-r--r--lua/muwiki/files.lua98
-rw-r--r--lua/muwiki/fs.lua85
-rw-r--r--lua/muwiki/handlers.lua44
-rw-r--r--lua/muwiki/health.lua69
-rw-r--r--lua/muwiki/init.lua48
-rw-r--r--lua/muwiki/links/creation.lua65
-rw-r--r--lua/muwiki/links/detection.lua58
-rw-r--r--lua/muwiki/links/navigation.lua20
-rw-r--r--lua/muwiki/links/open.lua94
-rw-r--r--lua/muwiki/links/url_handlers.lua71
-rw-r--r--lua/muwiki/paths.lua88
-rw-r--r--lua/muwiki/templates.lua35
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