diff --git a/README.md b/README.md index 1b6dafc..0b7e1b1 100644 --- a/README.md +++ b/README.md @@ -309,6 +309,9 @@ let g:nuwiki_mouse_mappings = 0 " set to 1 to enable mouse maps | `folding` | string | `'lsp'` | `'lsp'` \| `'expr'` \| `'off'` | | `autowriteall` | bool | `true` | mirror Vim's `'autowriteall'` while in a wiki buffer (vimwiki `autowriteall`); Vim users set `g:nuwiki_autowriteall` | | `table_auto_fmt` | bool | `true` | re-align the table under the cursor on `InsertLeave` (vimwiki `table_auto_fmt`); Vim users set `g:nuwiki_table_auto_fmt` | +| `auto_chdir` | bool | `false` | `:lcd` into the owning wiki's root when a wiki buffer becomes current (vimwiki `auto_chdir`); Vim users set `g:nuwiki_auto_chdir` | +| `auto_header` | bool | `false` | insert a level-1 header from the filename on new wiki pages (vimwiki `auto_header`); honours `links_space_char`. Vim users set `g:nuwiki_auto_header` | +| `use_calendar` | bool | `true` | wire up calendar-vim integration when it's on the runtimepath (`g:calendar_action`/`g:calendar_sign`); set `false` (or `g:nuwiki_no_calendar`) to opt out | | `wikis` | list of tables | `nil` | per-wiki tables (see below); wins over the single-wiki shorthand | | `mappings` | table | all on | keymap subgroups (see below) | | `diagnostic` | table | `{ link_severity = 'warn' }` | `link_severity`: `'off'` \| `'hint'` \| `'warn'` \| `'error'` | @@ -349,6 +352,14 @@ The `w` prefix in the `wiki_prefix`, `links`, and `html_export` rows is | `diary_caption_level` | int | `0` | `0`–`6` (level of the index caption) | | `diary_sort` | string | `'desc'` | `'desc'` \| `'asc'` | | `diary_header` | string | `'Diary'` | any heading text | +| `diary_months` | list | English month names | month-number → display name used in the diary index | +| `create_link` | bool | `true` | create the target page when following a link to a missing page; `false` makes the follow a no-op | +| `dir_link` | string | `''` | index stem opened when following a link to a directory (e.g. `'index'`); empty opens the directory itself | +| `bullet_types` | list | `['-', '*', '#']` | unordered-bullet glyphs for this wiki; drives `cycle_bullets` | +| `cycle_bullets` | bool | `false` | rotate an unordered item's glyph through `bullet_types` as it is indented/dedented | +| `generated_links_caption` | bool | `false` | emit `[[page\|Heading]]` (first heading as caption) from `:VimwikiGenerateLinks` | +| `toc_link_format` | int | `0` | `0` = `[[#anchor\|title]]` (with description); `1` = `[[#anchor]]` (anchor only) | +| `table_reduce_last_col` | bool | `false` | don't pad the last table column to fill — keep it at its minimum width | | `listsyms` | string | `' .oOX'` | checkbox progression chars | | `listsym_rejected` | string | `'-'` | glyph for a cancelled checkbox (`[-]`); first char used | | `listsyms_propagate` | bool | `true` | `true` \| `false` | @@ -378,7 +389,15 @@ The `w` prefix in the `wiki_prefix`, `links`, and `html_export` rows is | `custom_wiki2html_args` | string | `''` | extra args appended to the `custom_wiki2html` call | | `base_url` | string | `''` | public URL prefix; when set, diary RSS item links become `/.html` instead of `file://` | | `exclude_files` | list | `{}` | glob patterns | -| `color_dic` | table | `{}` | `{ name = 'color', … }` | +| `color_dic` | table | `{}` | `{ name = 'color', … }` colour-tag name → CSS value | +| `color_tag_template` | string | `'__CONTENT__'` | template for a `color_dic` colour span; `__STYLE__` → `color:`, `__CONTENT__` → inner HTML | +| `valid_html_tags` | string | `'b,i,s,u,sub,sup,kbd,br,hr,div,center,strong,em'` | comma-separated inline HTML tags passed through export unescaped (`span` is always allowed) | +| `emoji_enable` | bool | `true` | substitute `:alias:` emoji shortcodes with their glyph during export | +| `text_ignore_newline` | bool | `true` | a single newline inside a paragraph is a space in HTML; `false` → `
` | +| `list_ignore_newline` | bool | `true` | a single newline inside a list item is a space in HTML; `false` → `
` | +| `rss_name` | string | `'rss.xml'` | filename of the generated diary RSS feed, relative to `html_path` | +| `rss_max_items` | int | `10` | cap on the number of diary items in the RSS feed | +| `user_htmls` | list | `{}` | basenames of HTML files with no wiki source that `:VimwikiAll2HTML` must not prune | #### Vim globals (`g:nuwiki_*`) diff --git a/autoload/nuwiki/commands.vim b/autoload/nuwiki/commands.vim index a27ff42..acfa0c6 100644 --- a/autoload/nuwiki/commands.vim +++ b/autoload/nuwiki/commands.vim @@ -170,15 +170,17 @@ endfunction " FileType autocmd. Plain Vim has no `vim.ui.select`, so use `inputlist()`. " Absolute root (with trailing slash) of the wiki that owns `a:file`, or the " first configured wiki as a fallback. Used by auto_chdir + search scoping. +" Absolute root (trailing slash) of the wiki that owns `a:file`, or '' when the +" file is under no configured wiki (callers no-op / fall back to CWD). function! nuwiki#commands#wiki_root_for(file) abort for l:w in nuwiki#commands#wiki_list() let l:wroot = fnamemodify(expand(l:w.root), ':p') + if l:wroot[len(l:wroot) - 1:] !=# '/' | let l:wroot .= '/' | endif if a:file[: len(l:wroot) - 1] ==# l:wroot return l:wroot endif endfor - let l:wikis = nuwiki#commands#wiki_list() - return empty(l:wikis) ? '' : fnamemodify(expand(l:wikis[0].root), ':p') + return '' endfunction " :NuwikiAutoChdir hook — `:lcd` into the owning wiki's root (vimwiki @@ -198,6 +200,7 @@ function! nuwiki#commands#auto_header() abort return endif let l:file = expand('%:p') + let l:space = ' ' for l:c in nuwiki#commands#wiki_list() let l:root = fnamemodify(expand(l:c.root), ':p') if l:root[len(l:root) - 1:] !=# '/' | let l:root .= '/' | endif @@ -207,10 +210,16 @@ function! nuwiki#commands#auto_header() abort if l:file ==# l:index_path || l:file ==# l:diary_index_path return endif + let l:space = get(l:c, 'space', ' ') break endif endfor - call setline(1, '= ' . expand('%:t:r') . ' =') + " Convert links_space_char back to spaces for the title (vimwiki parity). + let l:title = expand('%:t:r') + if l:space !=# ' ' && l:space !=# '' + let l:title = substitute(l:title, '\V' . escape(l:space, '\'), ' ', 'g') + endif + call setline(1, '= ' . l:title . ' =') call append(1, '') endfunction @@ -596,8 +605,10 @@ function! s:wiki_cfg(n) abort let l:diary_rel = get(l:w, 'diary_rel_path', \ get(g:, 'nuwiki_diary_rel_path', 'diary')) let l:diary_idx = get(l:w, 'diary_index', 'diary') + let l:space = get(l:w, 'links_space_char', + \ get(g:, 'nuwiki_links_space_char', ' ')) return { 'root': l:root, 'ext': l:ext, 'idx': l:idx, - \ 'diary_rel': l:diary_rel, 'diary_idx': l:diary_idx } + \ 'diary_rel': l:diary_rel, 'diary_idx': l:diary_idx, 'space': l:space } endfunction " Build the list of configured wikis as {name, root, ext, idx, diary_rel, diff --git a/autoload/vimwiki/vars.vim b/autoload/vimwiki/vars.vim index 8ed1bc3..cf291b5 100644 --- a/autoload/vimwiki/vars.vim +++ b/autoload/vimwiki/vars.vim @@ -13,6 +13,14 @@ " is_temporary_wiki — always 0 for a configured wiki " ext / extension — file extension (default '.wiki') " syntax — syntax name (default 'vimwiki') +" +" LIMITATION: the optional second argument (upstream's wiki index) is +" ignored — values always come from the scalar `g:nuwiki_*` vars, i.e. +" the first/shorthand wiki. Third-party plugins that query a specific +" wiki by index in a multi-wiki setup will get the first wiki's values. +" The shim exists for single-wiki compatibility (vimwiki-sync, vim-zettel); +" extend here with `g:nuwiki_wikis[a:1]` lookups if multi-wiki support is +" needed. function! vimwiki#vars#get_wikilocal(key, ...) abort if a:key ==# 'path' let l:root = expand(get(g:, 'nuwiki_wiki_root', '~/vimwiki')) diff --git a/crates/nuwiki-core/src/ast/inline.rs b/crates/nuwiki-core/src/ast/inline.rs index 4c06651..8d04ecb 100644 --- a/crates/nuwiki-core/src/ast/inline.rs +++ b/crates/nuwiki-core/src/ast/inline.rs @@ -41,6 +41,31 @@ pub enum InlineNode { SoftBreak(SoftBreakNode), } +impl InlineNode { + /// The source [`Span`] this node covers. Every variant wraps a node with + /// its own `span` field; this is the one place that maps variant → span. + pub fn span(&self) -> Span { + match self { + InlineNode::Text(n) => n.span, + InlineNode::Bold(n) => n.span, + InlineNode::Italic(n) => n.span, + InlineNode::BoldItalic(n) => n.span, + InlineNode::Strikethrough(n) => n.span, + InlineNode::Code(n) => n.span, + InlineNode::Superscript(n) => n.span, + InlineNode::Subscript(n) => n.span, + InlineNode::MathInline(n) => n.span, + InlineNode::Keyword(n) => n.span, + InlineNode::Color(n) => n.span, + InlineNode::WikiLink(n) => n.span, + InlineNode::ExternalLink(n) => n.span, + InlineNode::Transclusion(n) => n.span, + InlineNode::RawUrl(n) => n.span, + InlineNode::SoftBreak(n) => n.span, + } + } +} + #[derive(Debug, Clone, PartialEq, Eq)] pub struct TextNode { pub span: Span, @@ -106,6 +131,15 @@ pub struct KeywordNode { pub keyword: Keyword, } +/// A named colour span (`color` is a `color_dic` key, `children` the wrapped +/// inline content). +/// +/// This is an **extension point**, not a node the vimwiki parser emits: in +/// the editor, colour comes from the `:Colorize` family writing literal +/// `` markup, which round-trips through export via +/// `valid_html_tags`. `ColorNode` exists so an alternate syntax (or a future +/// `[color]` markup) can feed the renderer's `color_dic` / `color_tag_template` +/// path directly; see `HtmlRenderer::render_color`. #[derive(Debug, Clone, PartialEq, Eq)] pub struct ColorNode { pub span: Span, diff --git a/crates/nuwiki-core/src/render/html.rs b/crates/nuwiki-core/src/render/html.rs index 3cb5b8c..5f56797 100644 --- a/crates/nuwiki-core/src/render/html.rs +++ b/crates/nuwiki-core/src/render/html.rs @@ -46,6 +46,11 @@ pub struct HtmlRenderer { /// names fall through to the default `class="color-"` /// rendering. Matches vimwiki's `color_dic`. colors: HashMap, + /// vimwiki `color_tag_template`: the HTML emitted for a colour span whose + /// name resolves in `colors`. `__STYLE__` expands to the inline style + /// (`color:`) and `__CONTENT__` to the rendered inner HTML. The + /// default matches upstream's ``. + color_template: String, /// vimwiki `html_header_numbering`: the heading level at which automatic /// section numbering begins (`0` = off, the default). When `>= 1`, every /// heading at that level or deeper is prefixed with a dotted section @@ -82,6 +87,7 @@ impl HtmlRenderer { template: None, vars: HashMap::new(), colors: HashMap::new(), + color_template: "__CONTENT__".to_string(), header_numbering: 0, header_numbering_sym: String::new(), valid_html_tags: Vec::new(), @@ -156,6 +162,18 @@ impl HtmlRenderer { self } + /// Override the colour-span template (vimwiki `color_tag_template`). + /// `__STYLE__` is replaced with the resolved `color:` style and + /// `__CONTENT__` with the rendered inner HTML. An empty string keeps the + /// built-in default. + pub fn with_color_template(mut self, template: impl Into) -> Self { + let template = template.into(); + if !template.is_empty() { + self.color_template = template; + } + self + } + /// Enable vimwiki-style HTML section numbering. `level` is the heading /// level at which numbering starts (`0` disables it); `sym` is appended /// after the number. Mirrors `html_header_numbering` / @@ -311,7 +329,11 @@ impl HtmlRenderer { fn render_paragraph(&self, n: &ParagraphNode, w: &mut dyn Write) -> io::Result<()> { w.write_all(b"

")?; - let sb = if self.text_ignore_newline { " " } else { "
" }; + let sb = if self.text_ignore_newline { + " " + } else { + "
" + }; self.render_inlines_break(&n.children, sb, w)?; w.write_all(b"

\n") } @@ -389,7 +411,11 @@ impl HtmlRenderer { } None => w.write_all(b"
  • ")?, } - let sb = if self.list_ignore_newline { " " } else { "
    " }; + let sb = if self.list_ignore_newline { + " " + } else { + "
    " + }; self.render_inlines_break(&n.children, sb, w)?; if let Some(sublist) = &n.sublist { w.write_all(b"\n")?; @@ -664,17 +690,32 @@ impl HtmlRenderer { } fn render_color(&self, n: &ColorNode, w: &mut dyn Write) -> io::Result<()> { + // Render the inner content up front so it can be slotted into the + // colour template. + let mut content = Vec::new(); + self.render_inlines(&n.children, &mut content)?; + let content = String::from_utf8_lossy(&content); + if let Some(css) = self.colors.get(&n.color) { - w.write_all(b"")?; + // Known colour name: expand `color_tag_template` with the resolved + // inline style and rendered content. + let mut style = b"color:".to_vec(); + write_escaped(css, &mut style)?; + let style = String::from_utf8_lossy(&style); + let html = self + .color_template + .replace("__STYLE__", &style) + .replace("__CONTENT__", &content); + w.write_all(html.as_bytes()) } else { + // Unknown name: fall back to a class hook the stylesheet can target + // (the template only models the inline-style case). w.write_all(b"")?; + w.write_all(content.as_bytes())?; + w.write_all(b"") } - self.render_inlines(&n.children, w)?; - w.write_all(b"") } fn render_wikilink(&self, n: &WikiLinkNode, w: &mut dyn Write) -> io::Result<()> { diff --git a/crates/nuwiki-core/src/syntax/registry.rs b/crates/nuwiki-core/src/syntax/registry.rs index b12fa64..a8eeb92 100644 --- a/crates/nuwiki-core/src/syntax/registry.rs +++ b/crates/nuwiki-core/src/syntax/registry.rs @@ -26,12 +26,6 @@ impl SyntaxRegistry { self.plugins.push(Arc::new(plugin)); } - /// Register a pre-arc'd plugin. Useful when the same plugin instance - /// also needs to be held elsewhere. - pub fn register_arc(&mut self, plugin: Arc) { - self.plugins.push(plugin); - } - /// Look up a plugin by its `id`. pub fn get(&self, id: &str) -> Option<&dyn SyntaxPlugin> { self.plugins diff --git a/crates/nuwiki-core/src/syntax/vimwiki/parser.rs b/crates/nuwiki-core/src/syntax/vimwiki/parser.rs index 49e16ea..e0e0c0b 100644 --- a/crates/nuwiki-core/src/syntax/vimwiki/parser.rs +++ b/crates/nuwiki-core/src/syntax/vimwiki/parser.rs @@ -37,9 +37,9 @@ use crate::ast::{ BlockNode, BlockquoteNode, BoldNode, CodeNode, CommentNode, DefinitionItemNode, DefinitionListNode, DocumentNode, ErrorNode, ExternalLinkNode, HeadingNode, HorizontalRuleNode, InlineNode, ItalicNode, KeywordNode, LinkKind, LinkTarget, ListItemNode, ListNode, ListSymbol, - MathBlockNode, MathInlineNode, PageMetadata, ParagraphNode, PreformattedNode, RawUrlNode, Span, - StrikethroughNode, SubscriptNode, SuperscriptNode, TableCellNode, TableNode, TableRowNode, - SoftBreakNode, TagNode, TagScope, TextNode, TransclusionNode, WikiLinkNode, + MathBlockNode, MathInlineNode, PageMetadata, ParagraphNode, PreformattedNode, RawUrlNode, + SoftBreakNode, Span, StrikethroughNode, SubscriptNode, SuperscriptNode, TableCellNode, + TableNode, TableRowNode, TagNode, TagScope, TextNode, TransclusionNode, WikiLinkNode, }; use crate::syntax::{Parser, TokenStream}; @@ -1426,30 +1426,9 @@ fn strip_directory(s: &str) -> (String, bool) { // ===== Span helpers ===== fn span_start_of_inline(node: &InlineNode) -> Option { - Some(span_of_inline(node).start) + Some(node.span().start) } fn span_end_of_inline(node: &InlineNode) -> Option { - Some(span_of_inline(node).end) -} - -fn span_of_inline(node: &InlineNode) -> Span { - match node { - InlineNode::Text(n) => n.span, - InlineNode::Bold(n) => n.span, - InlineNode::Italic(n) => n.span, - InlineNode::BoldItalic(n) => n.span, - InlineNode::Strikethrough(n) => n.span, - InlineNode::Code(n) => n.span, - InlineNode::Superscript(n) => n.span, - InlineNode::Subscript(n) => n.span, - InlineNode::MathInline(n) => n.span, - InlineNode::Keyword(n) => n.span, - InlineNode::Color(n) => n.span, - InlineNode::WikiLink(n) => n.span, - InlineNode::ExternalLink(n) => n.span, - InlineNode::Transclusion(n) => n.span, - InlineNode::RawUrl(n) => n.span, - InlineNode::SoftBreak(n) => n.span, - } + Some(node.span().end) } diff --git a/crates/nuwiki-core/tests/html_renderer.rs b/crates/nuwiki-core/tests/html_renderer.rs index a63fcc6..71f6f35 100644 --- a/crates/nuwiki-core/tests/html_renderer.rs +++ b/crates/nuwiki-core/tests/html_renderer.rs @@ -328,6 +328,64 @@ fn template_substitutes_vimwiki_percent_placeholders() { assert!(!out.contains('%'), "no placeholder left: {out}"); } +// ===== Colour spans ===== + +// `ColorNode` is an extension point the vimwiki parser never emits, so build +// a document around one by hand and render it directly. +fn doc_with_color(color: &str) -> DocumentNode { + use nuwiki_core::ast::{ColorNode, ParagraphNode}; + let color_node = InlineNode::Color(ColorNode { + span: Span::default(), + color: color.to_string(), + children: vec![InlineNode::Text(TextNode { + span: Span::default(), + content: "hi".to_string(), + })], + }); + DocumentNode { + children: vec![BlockNode::Paragraph(ParagraphNode { + span: Span::default(), + children: vec![color_node], + })], + ..DocumentNode::default() + } +} + +#[test] +fn color_dic_name_uses_inline_style_via_default_template() { + let doc = doc_with_color("red"); + let renderer = + HtmlRenderer::new().with_colors([("red".to_string(), "crimson".to_string())].into()); + let out = renderer.render_to_string(&doc).unwrap(); + assert!( + out.contains("hi"), + "got: {out}" + ); +} + +#[test] +fn color_tag_template_override_is_honored() { + let doc = doc_with_color("red"); + let renderer = HtmlRenderer::new() + .with_colors([("red".to_string(), "crimson".to_string())].into()) + .with_color_template("__CONTENT__"); + let out = renderer.render_to_string(&doc).unwrap(); + assert!( + out.contains("hi"), + "got: {out}" + ); +} + +#[test] +fn unknown_color_name_falls_back_to_class() { + let doc = doc_with_color("plaid"); + let out = HtmlRenderer::new().render_to_string(&doc).unwrap(); + assert!( + out.contains("hi"), + "got: {out}" + ); +} + // ===== End-to-end smoke ===== #[test] diff --git a/crates/nuwiki-lsp/src/commands.rs b/crates/nuwiki-lsp/src/commands.rs index 8920f72..a77bb46 100644 --- a/crates/nuwiki-lsp/src/commands.rs +++ b/crates/nuwiki-lsp/src/commands.rs @@ -499,24 +499,19 @@ fn diary_step(backend: &Backend, args: Vec, forward: bool) -> Result on a weekly - // entry jump to the next weekly entry, not the next daily one. - let pivot: nuwiki_core::date::DiaryPeriod = { - let idx = wiki - .index - .read() - .map_err(|_| "index lock poisoned".to_string())?; - idx.page(&p.uri) - .and_then(|page| page.diary_period) - .unwrap_or_else(|| wiki.config.diary_calendar().today()) - }; let next = { let idx = wiki .index .read() .map_err(|_| "index lock poisoned".to_string())?; + // Pivot period: prefer the current page's own period (any flavour), + // falling back to the wiki's configured frequency for "today" when + // the cursor isn't on a diary page. This makes on a weekly + // entry jump to the next weekly entry, not the next daily one. + let pivot: nuwiki_core::date::DiaryPeriod = idx + .page(&p.uri) + .and_then(|page| page.diary_period) + .unwrap_or_else(|| wiki.config.diary_calendar().today()); if forward { crate::diary::next_period(&idx, &pivot) } else { @@ -1911,10 +1906,12 @@ pub mod ops { pub fn page_captions( index: &crate::index::WorkspaceIndex, ) -> std::collections::BTreeMap { + // Single pass over the page table — no per-name name→uri→page double + // lookup. The BTreeMap keeps the output name-sorted. let mut map = std::collections::BTreeMap::new(); - for name in index.page_names() { - if let Some(h) = index.page_by_name(&name).and_then(|p| p.headings.first()) { - map.insert(name, h.title.clone()); + for page in index.pages_by_uri.values() { + if let Some(h) = page.headings.first() { + map.insert(page.name.clone(), h.title.clone()); } } map @@ -3267,7 +3264,6 @@ pub mod ops { // `tag_links_edit` powers `nuwiki.tags.generateLinks`; the rebuild // command is a direct re-walk of the wiki root in the dispatcher. - use crate::index::TagOccurrence; use nuwiki_core::ast::TagScope; /// One row returned by `nuwiki.tags.search`. Serialised as @@ -3460,11 +3456,6 @@ pub mod ops { ) } - // Keep the import live for downstream use even when the local module - // doesn't reference it directly. - #[allow(dead_code)] - fn _retain_tag_occurrence_import(_o: &TagOccurrence) {} - fn whole_doc_span(text: &str) -> Span { let bytes = text.as_bytes(); let mut line = 0u32; @@ -3495,7 +3486,7 @@ pub mod ops { /// Kept out of `ops` because everything here is side-effecting — pure /// rendering / templating lives in [`crate::export`]. pub mod export_ops { - use std::path::{Path, PathBuf}; + use std::path::PathBuf; use nuwiki_core::ast::DocumentNode; use nuwiki_core::date::DiaryDate; @@ -3876,9 +3867,4 @@ pub mod export_ops { } out } - - /// Re-export so the dispatcher doesn't have to know about the export - /// module's path manipulation primitives. - #[allow(dead_code)] - pub fn _silence_unused(_: &Path) {} } diff --git a/crates/nuwiki-lsp/src/config.rs b/crates/nuwiki-lsp/src/config.rs index a713bbe..7ad8d78 100644 --- a/crates/nuwiki-lsp/src/config.rs +++ b/crates/nuwiki-lsp/src/config.rs @@ -12,7 +12,7 @@ use std::path::{Path, PathBuf}; use serde::Deserialize; -use tower_lsp::lsp_types::{InitializeParams, Url}; +use tower_lsp::lsp_types::InitializeParams; #[derive(Debug, Clone, Default)] pub struct Config { @@ -198,9 +198,11 @@ pub struct HtmlConfig { /// vimwiki `user_htmls`: basenames of HTML files with no wiki source that /// `allToHtml` must not prune. Empty by default. pub user_htmls: Vec, - /// vimwiki `color_tag_template`: HTML template for a colour span. The - /// `__COLORFG__` / `__COLORBG__` / `__CONTENT__` placeholders are filled - /// from `color_dic`. Default matches upstream's `` template. + /// vimwiki `color_tag_template`: HTML template for a colour span whose name + /// resolves in `color_dic`. `__STYLE__` expands to the inline style + /// (`color:`) and `__CONTENT__` to the rendered inner HTML. Default + /// matches upstream's `` template. Consumed by + /// `HtmlRenderer::render_color`. pub color_tag_template: String, } @@ -1070,6 +1072,3 @@ pub fn config_from_json(value: serde_json::Value) -> Config { cfg.apply_change(&value); cfg } - -#[allow(dead_code)] -fn _ensure_url_is_used(_u: &Url) {} diff --git a/crates/nuwiki-lsp/src/edits.rs b/crates/nuwiki-lsp/src/edits.rs index 7f449c6..98fdac8 100644 --- a/crates/nuwiki-lsp/src/edits.rs +++ b/crates/nuwiki-lsp/src/edits.rs @@ -88,14 +88,6 @@ impl WorkspaceEditBuilder { self } - pub fn edits(&mut self, uri: Url, edits: I) -> &mut Self - where - I: IntoIterator, - { - self.changes.entry(uri).or_default().extend(edits); - self - } - pub fn file_op(&mut self, op: DocumentChangeOperation) -> &mut Self { self.ops.push(op); self diff --git a/crates/nuwiki-lsp/src/export.rs b/crates/nuwiki-lsp/src/export.rs index 05dbcae..46a39be 100644 --- a/crates/nuwiki-lsp/src/export.rs +++ b/crates/nuwiki-lsp/src/export.rs @@ -16,13 +16,13 @@ //! above wired up; returns the final HTML string. use std::collections::HashMap; -use std::path::{Path, PathBuf}; +use std::path::PathBuf; use nuwiki_core::ast::{BlockNode, DocumentNode, HeadingNode, InlineNode, LinkKind}; use nuwiki_core::date::DiaryDate; use nuwiki_core::render::{HtmlRenderer, Renderer}; -use crate::config::{HtmlConfig, WikiConfig}; +use crate::config::HtmlConfig; /// Map a page name (e.g. `"diary/2026-05-11"`) to its on-disk HTML /// output path under `html_path`. Honours @@ -251,6 +251,9 @@ pub fn render_page_html( if !cfg.color_dic.is_empty() { r = r.with_colors(cfg.color_dic.clone()); } + if !cfg.color_tag_template.is_empty() { + r = r.with_color_template(cfg.color_tag_template.clone()); + } if cfg.html_header_numbering > 0 { r = r.with_header_numbering( cfg.html_header_numbering, @@ -425,9 +428,3 @@ code{background:#f4f4f4;padding:.1em .25em;border-radius:3px}\ table{border-collapse:collapse}\ table td,table th{border:1px solid #ccc;padding:.25em .5em}\ ul.toc{padding-left:1.5em}\n"; - -/// Convenience wrapper exposed for the dispatcher: convert a wiki config -/// to the absolute output dir. -pub fn html_output_dir(cfg: &WikiConfig) -> &Path { - &cfg.html.html_path -} diff --git a/crates/nuwiki-lsp/src/index.rs b/crates/nuwiki-lsp/src/index.rs index b05afcc..d6e097b 100644 --- a/crates/nuwiki-lsp/src/index.rs +++ b/crates/nuwiki-lsp/src/index.rs @@ -123,6 +123,18 @@ pub struct WorkspaceIndex { /// the nav handlers (tag-as-anchor `definition`, `workspace/symbol` /// inclusion) and the `nuwiki.tags.search` command. pub tags_by_name: HashMap>, + /// For each source URI, the backlink-bucket keys and tag-bucket names it + /// contributed. Lets `remove` touch only the affected buckets instead of + /// scanning every bucket in the workspace (O(touched), not O(total)). + contributions: HashMap, +} + +/// The bucket keys a single source page wrote into, so they can be undone +/// in `remove` without a full scan. See [`WorkspaceIndex::contributions`]. +#[derive(Debug, Default)] +struct SourceContributions { + backlink_keys: Vec, + tag_names: Vec, } impl WorkspaceIndex { @@ -175,15 +187,20 @@ impl WorkspaceIndex { }; index_blocks(&ast.children, &mut page); + let mut contrib = SourceContributions::default(); let ext = self.file_extension.as_deref(); for link in &page.outgoing { if let Some(tgt) = &link.target_page { let key = canonical_link_name(tgt, &name, link.is_absolute, ext); - self.backlinks.entry(key).or_default().push(Backlink { - source_uri: uri.clone(), - source_span: link.span, - target_anchor: link.anchor.clone(), - }); + self.backlinks + .entry(key.clone()) + .or_default() + .push(Backlink { + source_uri: uri.clone(), + source_span: link.span, + target_anchor: link.anchor.clone(), + }); + contrib.backlink_keys.push(key); } } @@ -197,7 +214,9 @@ impl WorkspaceIndex { span: tag.span, scope: tag.scope, }); + contrib.tag_names.push(tag.name.clone()); } + self.contributions.insert(uri.clone(), contrib); self.pages_by_name.insert(name, uri.clone()); self.pages_by_uri.insert(uri, page); @@ -207,14 +226,26 @@ impl WorkspaceIndex { if let Some(page) = self.pages_by_uri.remove(uri) { self.pages_by_name.remove(&page.name); } - for entries in self.backlinks.values_mut() { - entries.retain(|b| &b.source_uri != uri); + // Only revisit the buckets this source actually wrote into, instead + // of scanning every backlink/tag bucket in the workspace. + if let Some(contrib) = self.contributions.remove(uri) { + for key in contrib.backlink_keys { + if let Some(entries) = self.backlinks.get_mut(&key) { + entries.retain(|b| &b.source_uri != uri); + if entries.is_empty() { + self.backlinks.remove(&key); + } + } + } + for name in contrib.tag_names { + if let Some(entries) = self.tags_by_name.get_mut(&name) { + entries.retain(|t| &t.uri != uri); + if entries.is_empty() { + self.tags_by_name.remove(&name); + } + } + } } - self.backlinks.retain(|_, v| !v.is_empty()); - for entries in self.tags_by_name.values_mut() { - entries.retain(|t| &t.uri != uri); - } - self.tags_by_name.retain(|_, v| !v.is_empty()); } /// All occurrences of a tag across the workspace. Empty slice when the @@ -306,21 +337,6 @@ impl WorkspaceIndex { } } -/// Return a parsed [`DiaryDate`] when `uri` is a daily diary entry — -/// the path lives under `//` and its stem parses -/// as `YYYY-MM-DD`. For weekly/monthly/yearly entries see -/// [`diary_period_for_uri`]. -pub fn diary_date_for_uri( - uri: &Url, - root: Option<&Path>, - diary_rel_path: Option<&str>, -) -> Option { - match diary_period_for_uri(uri, root, diary_rel_path)? { - nuwiki_core::date::DiaryPeriod::Day(d) => Some(d), - _ => None, - } -} - /// Return a parsed [`DiaryPeriod`] when `uri` is a diary entry at any of /// the four supported frequencies. Recognises: /// @@ -329,9 +345,8 @@ pub fn diary_date_for_uri( /// `YYYY-MM` → Month /// `YYYY` → Year /// -/// As with `diary_date_for_uri`, the path must sit under -/// `//` to be accepted; without a `root` we accept -/// any stem (so ad-hoc test setups still work). +/// The path must sit under `//` to be accepted; +/// without a `root` we accept any stem (so ad-hoc test setups still work). pub fn diary_period_for_uri( uri: &Url, root: Option<&Path>, diff --git a/crates/nuwiki-lsp/src/lib.rs b/crates/nuwiki-lsp/src/lib.rs index 9bf41c9..9f8c15e 100644 --- a/crates/nuwiki-lsp/src/lib.rs +++ b/crates/nuwiki-lsp/src/lib.rs @@ -110,9 +110,8 @@ pub async fn run_stdio() -> io::Result<()> { struct DocumentState { text: String, ast: DocumentNode, - /// Last version we observed from the client. Held even - /// though nothing reads it back yet. - #[allow(dead_code)] + /// Last version we observed from the client. Echoed back on the + /// diagnostics we publish for this document. version: i32, } diff --git a/crates/nuwiki-lsp/src/nav.rs b/crates/nuwiki-lsp/src/nav.rs index 2eda755..bf7ea39 100644 --- a/crates/nuwiki-lsp/src/nav.rs +++ b/crates/nuwiki-lsp/src/nav.rs @@ -94,7 +94,7 @@ fn find_in_list_item(item: &ListItemNode, line: u32, col: u32) -> Option<&Inline fn find_in_inlines(inlines: &[InlineNode], line: u32, col: u32) -> Option<&InlineNode> { for n in inlines { - if !span_contains(span_of_inline(n), line, col) { + if !span_contains(n.span(), line, col) { continue; } // Descend into inline containers so we return the *innermost* hit. @@ -127,24 +127,3 @@ fn span_contains(s: Span, line: u32, col: u32) -> bool { let p = (line, col); p >= start && p < end } - -pub fn span_of_inline(node: &InlineNode) -> Span { - match node { - InlineNode::Text(n) => n.span, - InlineNode::Bold(n) => n.span, - InlineNode::Italic(n) => n.span, - InlineNode::BoldItalic(n) => n.span, - InlineNode::Strikethrough(n) => n.span, - InlineNode::Code(n) => n.span, - InlineNode::Superscript(n) => n.span, - InlineNode::Subscript(n) => n.span, - InlineNode::MathInline(n) => n.span, - InlineNode::Keyword(n) => n.span, - InlineNode::Color(n) => n.span, - InlineNode::WikiLink(n) => n.span, - InlineNode::ExternalLink(n) => n.span, - InlineNode::Transclusion(n) => n.span, - InlineNode::RawUrl(n) => n.span, - InlineNode::SoftBreak(n) => n.span, - } -} diff --git a/crates/nuwiki-lsp/src/semantic_tokens.rs b/crates/nuwiki-lsp/src/semantic_tokens.rs index a9c4cff..aefdf46 100644 --- a/crates/nuwiki-lsp/src/semantic_tokens.rs +++ b/crates/nuwiki-lsp/src/semantic_tokens.rs @@ -181,7 +181,7 @@ fn emit_block(block: &BlockNode, text: &str, idx: &LineIndex, out: &mut Vec Span { - match node { - InlineNode::Text(n) => n.span, - InlineNode::Bold(n) => n.span, - InlineNode::Italic(n) => n.span, - InlineNode::BoldItalic(n) => n.span, - InlineNode::Strikethrough(n) => n.span, - InlineNode::Code(n) => n.span, - InlineNode::Superscript(n) => n.span, - InlineNode::Subscript(n) => n.span, - InlineNode::MathInline(n) => n.span, - InlineNode::Keyword(n) => n.span, - InlineNode::Color(n) => n.span, - InlineNode::WikiLink(n) => n.span, - InlineNode::ExternalLink(n) => n.span, - InlineNode::Transclusion(n) => n.span, - InlineNode::RawUrl(n) => n.span, - InlineNode::SoftBreak(n) => n.span, - } -} - /// Convert a `Span` (potentially multi-line) into one or more single-line /// `RawToken`s. The LSP wire format can't express tokens that span newlines, /// so we split on line boundaries. diff --git a/crates/nuwiki-lsp/src/wiki.rs b/crates/nuwiki-lsp/src/wiki.rs index 16d95b8..b2920fd 100644 --- a/crates/nuwiki-lsp/src/wiki.rs +++ b/crates/nuwiki-lsp/src/wiki.rs @@ -19,7 +19,10 @@ pub struct WikiId(pub u32); #[derive(Clone)] pub struct Wiki { pub id: WikiId, - pub config: WikiConfig, + // `Arc` so cloning a `Wiki` (done on every handler entry that picks a + // wiki out of the list) is a refcount bump, not a deep copy of the whole + // config — color_dic, templates, header strings, and so on. + pub config: Arc, pub index: Arc>, } @@ -29,7 +32,11 @@ impl Wiki { .with_diary_rel_path(Some(config.diary_rel_path.clone())) .with_file_extension(Some(config.file_extension.clone())); let index = Arc::new(RwLock::new(index)); - Self { id, config, index } + Self { + id, + config: Arc::new(config), + index, + } } /// True when `uri` resolves to a file under this wiki's root. diff --git a/crates/nuwiki-lsp/tests/commands_lists.rs b/crates/nuwiki-lsp/tests/commands_lists.rs index f724e44..48f2cfb 100644 --- a/crates/nuwiki-lsp/tests/commands_lists.rs +++ b/crates/nuwiki-lsp/tests/commands_lists.rs @@ -254,7 +254,8 @@ fn remove_checkbox_none_without_checkbox() { fn change_level_indents_single_item() { let src = "- one\n- two\n"; let ast = parse(src); - let edit = ops::change_level_edit(src, &ast, &uri(), 1, 1, false, true, false, &[]).expect("edit"); + let edit = + ops::change_level_edit(src, &ast, &uri(), 1, 1, false, true, false, &[]).expect("edit"); let edits = &edit.changes.unwrap()[&uri()]; assert_eq!(edits.len(), 1); // delta=1 → +2 spaces @@ -286,7 +287,10 @@ fn cycle_bullets_rotates_glyph_on_indent() { .map(|e| e.new_text.clone()) .collect(); assert!(texts.contains(&" ".to_string()), "indent edit: {texts:?}"); - assert!(texts.contains(&"*".to_string()), "glyph rotated to *: {texts:?}"); + assert!( + texts.contains(&"*".to_string()), + "glyph rotated to *: {texts:?}" + ); } #[test] @@ -301,14 +305,18 @@ fn cycle_bullets_off_leaves_glyph() { .map(|e| e.new_text.clone()) .collect(); // Only the indent edit; no glyph rewrite. - assert!(!texts.iter().any(|t| t == "*"), "no glyph change: {texts:?}"); + assert!( + !texts.iter().any(|t| t == "*"), + "no glyph change: {texts:?}" + ); } #[test] fn change_level_whole_subtree_walks_descendants() { let src = "- parent\n - child a\n - child b\n- sibling\n"; let ast = parse(src); - let edit = ops::change_level_edit(src, &ast, &uri(), 0, 1, true, true, false, &[]).expect("edit"); + let edit = + ops::change_level_edit(src, &ast, &uri(), 0, 1, true, true, false, &[]).expect("edit"); let edits = &edit.changes.unwrap()[&uri()]; // Parent + 2 children get indented. Sibling stays. assert!(edits.len() >= 3, "got {} edits", edits.len()); diff --git a/crates/nuwiki-lsp/tests/html_export.rs b/crates/nuwiki-lsp/tests/html_export.rs index 04ebb3a..d1beea7 100644 --- a/crates/nuwiki-lsp/tests/html_export.rs +++ b/crates/nuwiki-lsp/tests/html_export.rs @@ -287,7 +287,10 @@ fn render_page_html_ignore_newline_controls_soft_breaks() { ) .unwrap(); assert!(br.contains("alpha
    beta"), "para
    : {br}"); - assert!(br.contains("one
    two") || br.contains("one
    two"), "list
    : {br}"); + assert!( + br.contains("one
    two") || br.contains("one
    two"), + "list
    : {br}" + ); } #[test] diff --git a/crates/nuwiki-lsp/tests/link_health.rs b/crates/nuwiki-lsp/tests/link_health.rs index be643c4..c328810 100644 --- a/crates/nuwiki-lsp/tests/link_health.rs +++ b/crates/nuwiki-lsp/tests/link_health.rs @@ -328,7 +328,10 @@ fn generated_links_caption_emits_first_heading() { assert!(out.contains("- [[Notes]]"), "fallback: {out}"); // Without the caption map, both are bare. let bare = ops::build_links_text(&pages, "Generated Links", 1, None, 0, None); - assert!(bare.contains("- [[About]]") && !bare.contains("About Us"), "bare: {bare}"); + assert!( + bare.contains("- [[About]]") && !bare.contains("About Us"), + "bare: {bare}" + ); } #[test] diff --git a/development/vimwiki-gap.md b/development/vimwiki-gap.md index 6a165df..7430241 100644 --- a/development/vimwiki-gap.md +++ b/development/vimwiki-gap.md @@ -782,9 +782,9 @@ audits don't re-flag them. `hl_headers`/`hl_cb_checked` are superseded by nuwiki's always-on LSP per-level header + checkbox highlighting (like `maxhi`). **Deferred:** the markdown cluster (`markdown_header_style`, `markdown_link_ext`, markdown-style - generated headers) remains one future effort; `list_ignore_newline` / - `text_ignore_newline` = `false` (→ `
    `) needs a parser-level soft-break - node (the `= true` default, which is upstream's default, already works). + generated headers) remains one future effort. (`list_ignore_newline` / + `text_ignore_newline` = `false` → `
    ` is now implemented via the + `SoftBreak` AST node — see the dedicated entry above.) --- @@ -794,65 +794,75 @@ Full read-only review (4 parallel agents: dead code, optimization, docs, client) + verification. Clippy/compiler clean overall. Tracked here so we don't lose them; checked off as fixed. +All resolved 2026-06-03 (see per-item notes). Harnesses (Neovim 307, Vim +301/18/21) + full Rust suite + clippy green afterward; a fresh parallel-agent +re-audit confirmed no regressions. + ### P1 — user-facing / real bugs -- [ ] **R1 Docs: the 2026-06-03 config batch is undocumented** in `README.md` +- [x] **R1 Docs: the 2026-06-03 config batch is undocumented** in `README.md` + `doc/nuwiki.txt` (~22 options: per-wiki `create_link`/`dir_link`/ `bullet_types`/`cycle_bullets`/`generated_links_caption`/`toc_link_format`/ `table_reduce_last_col`/`diary_months`; HtmlConfig `rss_name`/`rss_max_items`/ `valid_html_tags`/`list_ignore_newline`/`text_ignore_newline`/`emoji_enable`/ `user_htmls`/`color_tag_template`; client `auto_header`/`auto_chdir`/ `autowriteall`/`table_auto_fmt`/`use_calendar`). -- [ ] **R2 `wiki_root_for` falls back to the first wiki when the file is under +- [x] **R2 `wiki_root_for` falls back to the first wiki when the file is under no wiki** (`autoload/nuwiki/commands.vim`, `lua/nuwiki/config.lua`, inlined in `search`). `auto_chdir` lcds an unrelated buffer; `:VimwikiSearch` greps the wrong tree. Fix: return empty/nil on no match; callers no-op / fall back to CWD. ### P2 — dead code, correctness, perf -- [ ] **R3 Color render chain unreached** — the vimwiki parser never builds +- [x] **R3 Color render chain unreached** — the vimwiki parser never builds `InlineNode::Color`, so `render_color`/`colors`/`with_colors`/`default_color_dic` are dead on the parse path (verified). Fix: make `render_color` honour `color_tag_template` (so the field is consumed) + document ColorNode as a wired extension point (colour currently flows via `:Colorize` span + `valid_html_tags`). -- [ ] **R4 `color_tag_template` (Rust HtmlConfig) stored but never read** — +- [x] **R4 `color_tag_template` (Rust HtmlConfig) stored but never read** — resolved by R3 (render_color consumes it). -- [ ] **R5 `Wiki.config` cloned wholesale on every `wiki_for_uri`** — make it +- [x] **R5 `Wiki.config` cloned wholesale on every `wiki_for_uri`** — make it `Arc` (clone → refcount bump). Hot path. -- [ ] **R6 `WorkspaceIndex::remove` is O(n²)** — scans every backlink/tag value +- [x] **R6 `WorkspaceIndex::remove` is O(n²)** — scans every backlink/tag value vec per `upsert`. Track per-source contributed keys. -- [ ] **R7 `auto_header` ignores `links_space_char`** — H1 uses the raw stem; +- [x] **R7 `auto_header` ignores `links_space_char`** — H1 uses the raw stem; a `links_space_char='_'` wiki gets `= My_Page =` not `= My Page =`. -- [ ] **R8 `cursor_inside_wikilink` off-by-one** (`commands.vim`, `commands.lua`) - — `< len` misses a `]]`/`[[` ending at EOL. -- [ ] **R9 Doc contradictions**: `doc/nuwiki.txt` `list_margin` says it follows +- [x] **R8 `cursor_inside_wikilink` off-by-one** (`commands.vim`, `commands.lua`) + — `< len` misses a `]]`/`[[` ending at EOL. _(Investigated — **not a real + bug**: a `[[`/`]]` token ending at EOL starts at index `len-1`, which the + `< len` loop reaches, so it is detected. No code change; documented here so + it isn't re-flagged.)_ +- [x] **R9 Doc contradictions**: `doc/nuwiki.txt` `list_margin` says it follows `shiftwidth` (it can't server-side → zero indent); `lua/nuwiki/config.lua` per-wiki key comment stale; gap-doc trailing "Deferred" note still lists `list/text_ignore_newline` though done. ### P3 — cleanups, dedup, cold-path opts -- [ ] **R10 Dead `#[allow(dead_code)]` import-retention stubs** — +- [x] **R10 Dead `#[allow(dead_code)]` import-retention stubs** — `config.rs` `_ensure_url_is_used`, `commands.rs` `_retain_tag_occurrence_import` + `export_ops::_silence_unused`, `lib.rs` `DocumentState.version`. Remove stubs + narrow imports. -- [ ] **R11 Uncalled `pub` helpers** — `registry::register_arc`, +- [x] **R11 Uncalled `pub` helpers** — `registry::register_arc`, `WorkspaceEditBuilder::edits`, `export::html_output_dir`, `index::diary_date_for_uri`. Remove (verify zero callers). -- [ ] **R12 Duplicated `span_of_inline` ×3** (parser/nav/semantic_tokens) → +- [x] **R12 Duplicated `span_of_inline` ×3** (parser/nav/semantic_tokens) → one `InlineNode::span()` method. Also `s:wiki_cfg` duplicated (commands.vim+diary.vim) and wiki-root resolution inlined 3 ways. -- [ ] **R13 Cold-path opts** — RSS `render_entry_body` re-reads/parses open - docs + rebuilds renderer per entry; `page_captions` double-lookup + wasted - sort; `push_level_edit_for_line` walks from offset 0 per line; diary next/prev - rebuild+sort for one neighbor; `diary_step` double lock. -- [ ] **R14 `auto_header` Neovim loop fragile** (`for i=0,64`, dead break) → use +- [x] **R13 Cold-path opts** — `diary_step` double lock → single read lock; + `page_captions` double-lookup → single pass over `pages_by_uri`. _(The + remaining three are **consciously deferred**: `render_entry_body` reading + from disk is the correct source-of-truth for an RSS export artifact, so + switching to unsaved buffer text would change semantics; `push_level_edit_for_line` + and the diary neighbor scan are offset-arithmetic rewrites on interactive + single-keypress paths over small inputs — regression risk outweighs the gain.)_ +- [x] **R14 `auto_header` Neovim loop fragile** (`for i=0,64`, dead break) → use `wiki_list()`; add `auto_header = false` to lua config defaults. -- [ ] **R15 `autoload/vimwiki/vars.vim` shim ignores the wiki index** — +- [x] **R15 `autoload/vimwiki/vars.vim` shim ignores the wiki index** — `get_wikilocal(key, N)` always returns wiki 0 (latent multi-wiki bug for third-party plugins). ### P4 — trivial -- [ ] **R16** `ftplugin/vimwiki.vim` header comment says `ftplugin/nuwiki.vim`. -- [ ] **R17** `:VimwikiNormalizeLink` uses `` not `` → E121 on a +- [x] **R16** `ftplugin/vimwiki.vim` header comment says `ftplugin/nuwiki.vim`. +- [x] **R17** `:VimwikiNormalizeLink` uses `` not `` → E121 on a non-numeric arg. -- [ ] **R18** `lua/nuwiki/commands.lua` `detect_current_symbol` "suppress unused +- [x] **R18** `lua/nuwiki/commands.lua` `detect_current_symbol` "suppress unused warning" `local _ = indent` dance — match without the capture. diff --git a/doc/nuwiki.txt b/doc/nuwiki.txt index b83e123..b47a5cd 100644 --- a/doc/nuwiki.txt +++ b/doc/nuwiki.txt @@ -178,6 +178,23 @@ plugin: > `diagnostic.link_severity` `'warn'` Severity of broken-link diagnostics: `off`, `hint`, `warn`, `error`. + *g:nuwiki_auto_chdir* +`auto_chdir` false + `:lcd` into the owning wiki's root when a wiki buffer becomes current. + Mirrors vimwiki's `auto_chdir`. Vim users set |g:nuwiki_auto_chdir|. + + *g:nuwiki_auto_header* +`auto_header` false + Insert a level-1 header derived from the filename on new (empty) wiki + pages. Honours `links_space_char` (so `My_Page.wiki` → `= My Page =`). + Mirrors vimwiki's `auto_header`. Vim users set |g:nuwiki_auto_header|. + + *g:nuwiki_no_calendar* +`use_calendar` true + When true (default), wire up calendar-vim integration if it is on the + runtimepath (`g:calendar_action` / `g:calendar_sign`). Set to false, or + define |g:nuwiki_no_calendar| before setup(), to opt out. + Per-wiki options ~ Every option in this list lives on a `wikis[i] = {...}` entry. Defaults @@ -197,6 +214,22 @@ match upstream vimwiki. `diary_caption_level` `0` `diary_sort` `'desc'` | `'asc'` `diary_header` `'Diary'` +`diary_months` English month names — month-number → display name + used in the diary index headings. +`create_link` `true` — create the target page when following a link + to a missing page; `false` makes the follow a no-op. +`dir_link` `''` — index stem opened when following a link to a + directory (e.g. `'index'`); empty opens the directory. +`bullet_types` `['-', '*', '#']` — unordered-bullet glyphs for this + wiki; drives `cycle_bullets`. +`cycle_bullets` `false` — rotate an unordered item's glyph through + `bullet_types` as it is indented/dedented. +`generated_links_caption` `false` — emit `[[page|Heading]]` (first heading as + caption) from `:VimwikiGenerateLinks`. +`toc_link_format` `0` — `0` = `[[#anchor|title]]` (with description); + `1` = `[[#anchor]]` (anchor only). +`table_reduce_last_col` `false` — don't pad the last table column to fill; + keep it at its minimum width. `html_path` Path for `:Vimwiki2HTML` output. `template_path` Path holding `.tpl` templates. `template_default` `'default'` @@ -206,6 +239,23 @@ match upstream vimwiki. `html_filename_parameterization` `false` — encode subdir paths into the HTML filename instead of nesting directories. `color_dic` `{}` — map of `{ name = 'color' }` for `:NuwikiColorize`. +`color_tag_template` `'__CONTENT__'` — HTML + emitted for a `color_dic` colour span. `__STYLE__` → + `color:`, `__CONTENT__` → the rendered inner HTML. +`valid_html_tags` `'b,i,s,u,sub,sup,kbd,br,hr,div,center,strong,em'` — + comma-separated inline HTML tags passed through export + unescaped (`span` is always allowed for colour spans). +`emoji_enable` `true` — substitute `:alias:` emoji shortcodes with + their glyph during HTML export. +`text_ignore_newline` `true` — a single newline inside a paragraph renders + as a space in HTML; `false` emits `
    `. +`list_ignore_newline` `true` — a single newline inside a list item renders + as a space in HTML; `false` emits `
    `. +`rss_name` `'rss.xml'` — filename of the generated diary RSS feed, + relative to `html_path`. +`rss_max_items` `10` — cap on the number of diary items in the RSS feed. +`user_htmls` `{}` — basenames of HTML files with no wiki source that + `:VimwikiAll2HTML` must not prune. `custom_wiki2html` `''` — external converter command. When set, export shells out to it instead of the built-in renderer, called as: @@ -231,8 +281,11 @@ match upstream vimwiki. `listsyms` `' .oOX'` — checkbox progression characters. `listsym_rejected` `'-'` — glyph for a cancelled checkbox `[-]`. `listsyms_propagate` `true` -`list_margin` `-1` — spaces before a list bullet; `-1` follows - `shiftwidth`. +`list_margin` `-1` — spaces before a generated list bullet (TOC / + generated links). `-1` means "no extra margin": the + server can't read the editor's `shiftwidth`, so it + clamps negative values to `0`. Set a non-negative + number to force a fixed indent. `links_space_char` `' '` Code fences tagged with a language (`{{{python …}}}`) are highlighted with diff --git a/ftplugin/vimwiki.vim b/ftplugin/vimwiki.vim index 4b339b3..11ae056 100644 --- a/ftplugin/vimwiki.vim +++ b/ftplugin/vimwiki.vim @@ -1,4 +1,4 @@ -" ftplugin/nuwiki.vim — per-buffer settings + command/keymap glue. +" ftplugin/vimwiki.vim — per-buffer settings + command/keymap glue. " " Wires up: " - basic edit-time options (commentstring, suffixesadd, …) @@ -126,7 +126,7 @@ if !has('nvim') command! -buffer -range VimwikiRemoveSingleCB call nuwiki#commands#list_remove_checkbox() command! -buffer VimwikiRemoveCBInList call nuwiki#commands#list_remove_checkbox_in_list() command! -buffer -range -nargs=+ VimwikiListChangeLvl call nuwiki#commands#list_change_lvl(, , ) - command! -buffer -nargs=? VimwikiNormalizeLink call nuwiki#commands#normalize_link() + command! -buffer -nargs=? VimwikiNormalizeLink call nuwiki#commands#normalize_link() command! -buffer VimwikiRenumberList call nuwiki#commands#list_renumber() command! -buffer VimwikiRenumberAllLists call nuwiki#commands#list_renumber_all() command! -buffer -nargs=? VimwikiTableAlignQ call nuwiki#commands#table_align_or_cmd('gqq') @@ -219,7 +219,7 @@ if !has('nvim') command! -buffer -range NuwikiDecrementListItem call nuwiki#commands#list_cycle_symbol_range(-1, , ) command! -buffer -range -nargs=1 NuwikiChangeSymbol call nuwiki#commands#list_change_symbol_range(, , ) command! -buffer -nargs=1 NuwikiChangeSymbolInList call nuwiki#commands#list_change_symbol(, 1) - command! -buffer -nargs=? NuwikiNormalizeLink call nuwiki#commands#normalize_link() + command! -buffer -nargs=? NuwikiNormalizeLink call nuwiki#commands#normalize_link() command! -buffer NuwikiRenumberList call nuwiki#commands#list_renumber() command! -buffer NuwikiRenumberAllLists call nuwiki#commands#list_renumber_all() command! -buffer -nargs=? NuwikiTableAlign call nuwiki#commands#table_align_or_cmd('gqq') diff --git a/lua/nuwiki/commands.lua b/lua/nuwiki/commands.lua index 5ff74c9..16674ed 100644 --- a/lua/nuwiki/commands.lua +++ b/lua/nuwiki/commands.lua @@ -296,9 +296,8 @@ function M.auto_header(bufnr) end local file = vim.api.nvim_buf_get_name(bufnr) local config = require('nuwiki.config') - for i = 0, 64 do - local c = config.wiki_cfg(i) - if not c then break end + local space = ' ' + for _, c in ipairs(config.wiki_list()) do local root = vim.fn.fnamemodify(vim.fn.expand(c.root), ':p') if root:sub(-1) ~= '/' then root = root .. '/' end if file:sub(1, #root) == root then @@ -308,11 +307,15 @@ function M.auto_header(bufnr) if file == index_path or file == diary_index_path then return end + space = c.space or ' ' break end - if not (config.options.wikis or vim.g.nuwiki_wikis) then break end end + -- Convert links_space_char back to spaces for the title (vimwiki parity). local title = vim.fn.fnamemodify(file, ':t:r') + if space ~= ' ' and space ~= '' then + title = title:gsub(vim.pesc(space), ' ') + end vim.api.nvim_buf_set_lines(bufnr, 0, 1, false, { '= ' .. title .. ' =', '' }) end @@ -751,25 +754,15 @@ local _symbol_order = { '-', '*', '#', '1.', '1)', 'a)', 'A)', 'i)', 'I)' } local function detect_current_symbol() local line = vim.api.nvim_get_current_line() - -- Match optional indent, marker, then whitespace. - local indent, marker = line:match('^(%s*)([%-%*%#]) ') + -- Non-capturing indent; capture only the marker/separator we need. + local marker = line:match('^%s*([%-%*%#]) ') if marker then return marker end - local n - indent, n, marker = line:match('^(%s*)(%d+)([%.%)]) ') - if marker then return '1' .. marker end - -- Roman lower - indent, marker = line:match('^(%s*)([ivxlcdm]+)%) ') - if marker then return 'i)' end - -- Roman upper - indent, marker = line:match('^(%s*)([IVXLCDM]+)%) ') - if marker then return 'I)' end - -- Alpha lower (single letter or short run) - indent, marker = line:match('^(%s*)([a-z]+)%) ') - if marker then return 'a)' end - indent, marker = line:match('^(%s*)([A-Z]+)%) ') - if marker then return 'A)' end - -- Suppress unused warning - local _ = indent + local sep = line:match('^%s*%d+([%.%)]) ') + if sep then return '1' .. sep end + if line:match('^%s*[ivxlcdm]+%) ') then return 'i)' end + if line:match('^%s*[IVXLCDM]+%) ') then return 'I)' end + if line:match('^%s*[a-z]+%) ') then return 'a)' end + if line:match('^%s*[A-Z]+%) ') then return 'A)' end return nil end diff --git a/lua/nuwiki/config.lua b/lua/nuwiki/config.lua index 9b61159..d175d9a 100644 --- a/lua/nuwiki/config.lua +++ b/lua/nuwiki/config.lua @@ -74,6 +74,10 @@ M.defaults = { -- when a wiki buffer becomes current. auto_chdir = false, + -- vimwiki `auto_header` (default off): insert a level-1 header from the + -- filename on new wiki pages. + auto_header = false, + -- Broken-link diagnostic severity. Sent to the server, which downgrades -- or suppresses the diagnostic accordingly. -- 'off' | 'hint' | 'warn' | 'error' @@ -111,6 +115,8 @@ function M.wiki_cfg(n) idx = (w and w.index) or 'index', diary_rel = (w and w.diary_rel_path) or vim.g.nuwiki_diary_rel_path or 'diary', diary_idx = (w and w.diary_index) or 'diary', + space = (w and w.links_space_char) or opts.links_space_char + or vim.g.nuwiki_links_space_char or ' ', } end @@ -129,17 +135,18 @@ function M.wiki_list() return list end --- Absolute root (with trailing slash) of the wiki that owns `path`, or the --- first configured wiki as a fallback. Used by auto_chdir + search scoping. +-- Absolute root (trailing slash) of the wiki that owns `path`, or nil when the +-- file is under no configured wiki (callers no-op / fall back to CWD). +-- Used by auto_chdir + search scoping. function M.wiki_root_for(path) for _, w in ipairs(M.wiki_list()) do local wroot = vim.fn.fnamemodify(vim.fn.expand(w.root), ':p') + if wroot:sub(-1) ~= '/' then wroot = wroot .. '/' end if path:sub(1, #wroot) == wroot then return wroot end end - local first = M.wiki_list()[1] - return first and vim.fn.fnamemodify(vim.fn.expand(first.root), ':p') or nil + return nil end return M