`{{url|alt|key="quoted value"}}` syntax was already parsed end-to-end
through to `TransclusionNode::attrs`, but the value retained the
surrounding `"…"` (or `'…'`) characters verbatim. The HTML renderer
then escaped them into `"`, producing `style=""border: 1px""`
instead of clean `style="border: 1px"`.
Extracted a small `insert_attr` helper that splits on `=`, trims, and
strips matching single- or double-quote pairs from the value before
inserting. Unquoted values pass through unchanged.
Tests: 4 new in crates/nuwiki-core/tests/transclusion_attrs.rs
covering double-quote stripping, single-quote stripping, unquoted
pass-through, and the clean-HTML rendering shape (no `"`).
Note: this commit covers the "transclusion attrs" half of the
original Cluster 8 audit. The "multi-line list-item continuation
paragraphs" half requires lexer + parser changes (continuation lines
indented past the marker should attach to the prior list item
instead of becoming a sibling paragraph) and is deferred.
Gates: 429 Rust / 39 Neovim / 12 Vim.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
nuwiki
A vimwiki-compatible Vim/Neovim plugin backed by a Rust language server.
nuwiki replaces the original vimwiki
plugin while keeping its file format, keymaps, and :Vimwiki* command
surface intact. The substantive work happens in a Rust LSP daemon — Vim
and Neovim are thin client layers that just wire up keystrokes and
display results.
- ✅ Full vimwiki syntax (
.wiki) — headings, lists with checkboxes, tables, math, code, tags, links (wiki / interwiki / diary / file / raw URL / transclusion). - ✅ Goto-definition, backlinks, hover, completion, workspace symbol search, semantic highlighting — all over LSP.
- ✅ Diary (today/yesterday/tomorrow/next/prev), tags (search + link generation), TOC generation, broken-link diagnostics, orphan finder.
- ✅ Multi-wiki with cross-wiki interwiki links (
[[wn.name:Page]]). - ✅ HTML export with templates, CSS, auto-export on save, RSS feed.
- ✅ Folding (LSP
foldingRange+ regex fallback), header & link text objects, the<CR>two-step link-create-and-follow. - ✅
:Vimwiki*command compat +:Nuwiki*canonical aliases.
See SPEC.md for the architecture, design decisions, and
the full phase log.
Installation
The plugin ships a nuwiki-ls binary that the editor talks to over
LSP stdio. The install() Lua helper downloads a pre-built release for
your platform, falling back to cargo build --release if no asset
matches.
lazy.nvim
{
'gffranco/nuwiki',
build = ":lua require('nuwiki').install()",
ft = { 'vimwiki' },
opts = {
wiki_root = '~/vimwiki',
},
}
vim-plug
Plug 'gffranco/nuwiki', { 'do': 'vim -e -s -c "source scripts/download_bin.vim" -c "q"' }
In init.lua / init.vim:
require('nuwiki').setup({ wiki_root = '~/vimwiki' })
Dein
call dein#add('gffranco/nuwiki', {
\ 'build': 'vim -e -s -c "source scripts/download_bin.vim" -c "q"'
\ })
Plain Vim (no plugin manager)
git clone https://code.gfran.co/gffranco/nuwiki ~/.vim/pack/gffranco/start/nuwiki
cd ~/.vim/pack/gffranco/start/nuwiki && cargo build --release -p nuwiki-ls
mkdir -p bin && ln -s target/release/nuwiki-ls bin/nuwiki-ls
Then ensure an LSP client is loaded (Vim path prefers
vim-lsp, falls back to
coc.nvim). Neovim 0.11+ uses
its built-in LSP client — no extra plugin needed.
Trying it without touching your config
./start-nvim.sh # spawns Neovim with a scratch wiki under
# $XDG_CACHE_HOME/nuwiki-dev
./start-vim.sh # same, but for plain Vim — clones vim-lsp
# into the cache on first run
Both scripts build the LSP binary, seed a sample wiki, and launch
the editor against an isolated state dir. Pass a wiki file path to
open it directly; NUWIKI_DEV_WIKI=/path/to/wiki points at your
own sandbox instead.
Configuration
Defaults are vimwiki-compatible. Full schema (with type annotations and
defaults) lives in lua/nuwiki/config.lua.
require('nuwiki').setup({
-- Single-wiki shorthand (vimwiki-compatible).
wiki_root = '~/vimwiki',
file_extension = '.wiki',
syntax = 'vimwiki', -- 'vimwiki' | 'markdown' (future)
log_level = 'warn',
-- Or the multi-wiki form. The single-wiki keys above desugar into
-- a one-entry `wikis = [...]` list, so you can pick either shape.
wikis = {
{
name = 'personal',
root = '~/vimwiki',
file_extension = '.wiki',
diary_rel_path = 'diary',
diary_index = 'diary',
-- HTML export (Phase 17)
html_path = '~/vimwiki/_html',
template_path = '~/vimwiki/_templates',
template_default = 'default',
template_ext = '.tpl',
css_name = 'style.css',
auto_export = false, -- export on save
exclude_files = {}, -- glob patterns
},
},
-- Keymap groups — flip subgroups off independently. Defaults all on.
mappings = {
enabled = true,
wiki_prefix = true, -- <Leader>w*
links = true, -- <CR>, <S-CR>, <Tab>, <BS>, +, …
lists = true, -- <C-Space>, gln/glp/glx, gl<Space>, o/O
headers = true, -- =, -, ]], [[, ]=, [=, ]u, [u
table_editing = true, -- gqq, gq1, gww, gw1, <A-Left>, <A-Right>
diary = true, -- <C-Down>, <C-Up>
html_export = true, -- <Leader>wh, <Leader>whh
text_objects = true, -- ah, ih
},
-- Folding strategy: 'lsp' (server-driven foldingRange — default on
-- Neovim 0.11+), 'expr' (pure regex), or 'off'.
folding = 'lsp',
-- Broken-link diagnostic severity: 'off' | 'hint' | 'warn' | 'error'.
diagnostic = {
link_severity = 'warn',
},
})
Vim users without Lua should set the equivalent globals before sourcing the plugin:
let g:nuwiki_wiki_root = '~/vimwiki'
let g:nuwiki_file_extension = '.wiki'
let g:nuwiki_log_level = 'warn'
let g:nuwiki_no_default_mappings = 0 " set to 1 to skip the keymap layer
Usage
Commands
Both :Vimwiki* (migration compat) and :Nuwiki* (canonical native)
are registered as buffer-local commands on every .wiki buffer.
Selected highlights — see :Vimwiki<Tab> for the full list.
| Command | What it does |
|---|---|
:VimwikiIndex [N] |
Open wiki N's index.wiki (default: 1) |
:VimwikiUISelect |
Pick a wiki from a list (multi-wiki only) |
:VimwikiMakeDiaryNote |
Open today's diary page |
:VimwikiDiaryNextDay / :VimwikiDiaryPrevDay |
Chronological diary nav |
:VimwikiTOC |
Generate / refresh table of contents on the current page |
:VimwikiGenerateLinks |
Flat list of every page in the wiki |
:VimwikiCheckLinks |
Send broken links to the quickfix list |
:VimwikiSearchTags {tag} |
Quickfix of all occurrences of :tag: |
:VimwikiGenerateTagLinks [tag] |
Insert a section linking all pages with <tag> |
:VimwikiRebuildTags |
Force a full workspace re-index |
:VimwikiRenameFile |
Rename current page; rewrites incoming links |
:VimwikiDeleteFile |
Delete current page (server-side DeleteFile) |
:Vimwiki2HTML |
Render current page to HTML |
:Vimwiki2HTMLBrowse |
Render + open in default browser |
:VimwikiAll2HTML[!] |
Export the whole wiki (! forces, otherwise incremental) |
:VimwikiRss |
Write rss.xml of diary entries |
:VimwikiFollowLink |
Follow link under cursor (<CR>) |
:VimwikiBacklinks |
Show references to current page |
Default keymaps
All buffer-local. Disable any group via mappings.<group> = false in
Neovim, or let g:nuwiki_no_default_mappings = 1 in Vim.
Links
| Key | Action |
|---|---|
<CR> |
Smart follow: inside [[…]] → jump; on a bare word → wrap as [[word]] (second <CR> follows) |
<S-CR> |
Follow in horizontal split |
<C-CR> |
Follow in vertical split |
<C-S-CR> |
Follow in new tab |
<BS> |
Jump back (<C-o>) |
<Tab> / <S-Tab> |
Next / previous wikilink on or after the cursor |
Lists
| Key | Action |
|---|---|
<C-Space> / <C-@> / <Nul> |
Toggle checkbox (terminal byte aliases) |
gln / glp |
Cycle checkbox state (forward / backward) |
glx |
Toggle the rejected marker [-] |
gnt |
Jump to the next unfinished task |
o / O |
Open a new line and continue the list marker (and checkbox if any) |
Headers
| Key | Action |
|---|---|
= / - |
Add / remove a heading level |
]] / [[ |
Next / previous heading |
]= / [= |
Next / previous sibling heading |
]u / [u |
Jump to parent heading |
Wiki / diary / export
| Key | Action |
|---|---|
<Leader>ww |
Today's diary |
<Leader>ws |
Pick a wiki (vim.ui.select / inputlist) |
<Leader>wi |
Diary index |
<Leader>w<Leader>w / <Leader>w<Leader>y / <Leader>w<Leader>t |
Today / yesterday / tomorrow |
<Leader>w<Leader>i |
Rebuild diary index page |
<Leader>wn / <Leader>wd / <Leader>wr |
Goto / delete / rename page |
<Leader>wh / <Leader>whh |
Export current page → HTML / open in browser |
<Leader>wha |
Export every page |
<C-Down> / <C-Up> |
Next / previous diary entry |
Text objects
| Key | Selection |
|---|---|
ah / ih |
A heading (with / without the heading line itself) |
Additional text objects (aH, iH, a\, i\, ac, ic, al,
il) are planned follow-ups now that the table/list rewriter
commands they depend on have shipped.
Health check
:checkhealth nuwiki
Reports the binary path, the LSP client status, registered
executeCommand entries, whether foldingRange was negotiated, and
whether the configured HTML output directory is writable.
Migrating from vimwiki
The file extension, syntax, command names, and default keymaps all
match vimwiki. Drop the original vimwiki plugin from your config,
install nuwiki, and point wiki_root at the same directory. Existing
pages, diary entries, tags, and templates Just Work.
Two known behavior deltas vs. upstream vimwiki:
- nuwiki's index is built lazily on workspace scan; the very first
:VimwikiCheckLinksafter launch may be empty until the background task completes. Subsequent runs are instant. glnandglpboth callnuwiki.list.cycleCheckbox, which is forward-only at present. A reverse cycle lands once the v1.1 list rewriters do.
Repository layout
nuwiki/
├── Cargo.toml workspace root
├── crates/
│ ├── nuwiki-core/ parser, AST, renderer (editor-independent)
│ ├── nuwiki-lsp/ LSP protocol bridge
│ └── nuwiki-ls/ stdio LSP binary entry point
├── plugin/ universal Vim/Neovim entry point
├── lua/nuwiki/ Neovim Lua glue
├── autoload/nuwiki/ Vim VimL glue
├── ftdetect/ filetype detection for .wiki
├── ftplugin/vimwiki.vim per-buffer settings + commands + keymaps
├── syntax/vimwiki.vim static fallback highlighting
├── doc/ `:h nuwiki`
├── scripts/ test harness + build helpers
└── .gitea/workflows/ CI / release pipelines
Development
Build + test
cargo build --workspace
cargo test --workspace # 381 Rust tests
./scripts/test-keymaps.sh # 19 Neovim keymap cases (LSP-driven)
./scripts/test-keymaps-vim.sh # 12 Vim keymap cases (pure-VimL)
CI runs all four on every push (.gitea/workflows/ci.yaml). MSRV: Rust
1.83.
Trying changes against a real wiki
./start-nvim.sh # isolated state under
./start-vim.sh # $XDG_CACHE_HOME/nuwiki-dev
Both regenerate their minimal config on every launch and seed a scratch wiki with sample pages, links, checkboxes, and tags.
Implementation status
v1.0 — foundation
| Phase | Name | Status |
|---|---|---|
| 0 | Scaffolding | ✅ done |
| 1 | Core AST | ✅ done |
| 2 | Syntax Plugin Interface | ✅ done |
| 3 | Vimwiki Lexer | ✅ done |
| 4 | Vimwiki Parser | ✅ done |
| 5 | Renderer | ✅ done |
| 6 | LSP Foundation | ✅ done |
| 7 | Semantic Tokens | ✅ done |
| 8 | Navigation | ✅ done |
| 9 | Editor Glue | ✅ done |
| 10 | CI/CD release pipeline | ✅ done |
v1.1 — full vimwiki replacement
| Phase | Name | Status |
|---|---|---|
| 11 | Plumbing prerequisites | ✅ done |
| 12 | Tags | ✅ done |
| 13 | Workspace edits + executeCommand | ✅ done |
| 14 | List & table edit commands | ✅ done |
| 15 | Link health + TOC/index generation | ✅ done |
| 16 | Diary | ✅ done |
| 17 | HTML export commands | ✅ done |
| 18 | Multi-wiki | ✅ done |
| 19 | Editor glue v2 (:Vimwiki* compat, keymaps, text objects, folding) |
✅ done |
License
Dual-licensed under either of:
- Apache License, Version 2.0 (
LICENSE-APACHE) - MIT license (
LICENSE-MIT)
at your option. Unless you explicitly state otherwise, any contribution intentionally submitted for inclusion in this work shall be dual licensed as above, without any additional terms or conditions.