diff --git a/README.md b/README.md index 7206819..242d3f5 100644 --- a/README.md +++ b/README.md @@ -22,9 +22,7 @@ display results. - ✅ `:Vimwiki*` command compat + `:Nuwiki*` canonical aliases. See [`SPEC.md`](./SPEC.md) for the architecture, design decisions, and -the full phase log; the [pending-work section](./SPEC.md#13-pending-implementation) -lists the few items still in flight (table/list rewriter commands and -colorize). +the full phase log. --- @@ -251,9 +249,8 @@ Neovim, or `let g:nuwiki_no_default_mappings = 1` in Vim. | `ah` / `ih` | A heading (with / without the heading line itself) | Additional text objects (`aH`, `iH`, `a\`, `i\`, `ac`, `ic`, `al`, -`il`) ship once the table/list rewriter commands from -[SPEC §13.1](./SPEC.md#131-phase-14--listtablelinkcolorize-commands) -land. +`il`) are planned follow-ups now that the table/list rewriter +commands they depend on have shipped. ### Health check @@ -352,7 +349,7 @@ scratch wiki with sample pages, links, checkboxes, and tags. | 11 | Plumbing prerequisites | ✅ done | | 12 | Tags | ✅ done | | 13 | Workspace edits + executeCommand | ✅ done | -| 14 | List & table edit commands | ✅ done (focused subset; table/list-renumber/colorize deferred — see [SPEC §13.1](./SPEC.md#131-phase-14--listtablelinkcolorize-commands)) | +| 14 | List & table edit commands | ✅ done | | 15 | Link health + TOC/index generation | ✅ done | | 16 | Diary | ✅ done | | 17 | HTML export commands | ✅ done | diff --git a/SPEC.md b/SPEC.md index 9cd61ea..3da8bfe 100644 --- a/SPEC.md +++ b/SPEC.md @@ -1335,39 +1335,27 @@ so a future audit doesn't have to re-derive the scope from the per-phase prose. Each entry includes the originating SPEC section, why it was deferred, and a sketch of what the implementation needs. -### 13.1 Phase 14 — list/table/link/colorize commands +### 13.1 Phase 14 — list/table/link/colorize commands ✅ -Phase 14 shipped a focused subset (checkbox toggle/cycle/reject, -nextTask navigation, heading promote/demote). The structural-rewriter -commands below were deferred — each needs its own AST-aware rewriter, -and they don't share enough scaffolding to land together cheaply. +All eleven commands deferred from Phase 14 are now shipped: -| Command | SPEC | What it does | Sketch | -|---|---|---|---| -| `nuwiki.list.changeSymbol` | §12.5 | Rewrite list marker (`-` → `*` → `#` → `1.` …), single item or whole list (`whole_list: bool` arg). | Locate item span via existing `find_list_item_at`; rewrite the marker token; if `whole_list` and numeric, renumber. | -| `nuwiki.list.changeLevel` | §12.5 | Indent/dedent one item or a subtree (`delta: i32`, `whole_subtree: bool`). | Compute leading-whitespace delta per line; re-indent each line of the item span; if subtree, descend into `ListItemNode.sublist`. | -| `nuwiki.list.renumber` | §12.5 | Re-sequence numeric markers across a list or a whole file (`whole_file: bool`). | Walk every `ListNode` whose `symbol` is `Numeric`/`NumericParen`/`AlphaParen`/…; rewrite the marker substrings in source order. | -| `nuwiki.list.removeDone` | §12.5 | Strip `[X]` / `[-]` items and their checked children. | Recursive list walker emitting delete spans for matching items; also delete the trailing newline so the surrounding list stays valid. | -| `nuwiki.table.align` | §12.5 | Reformat columns to max width. | Two-pass: width-per-column scan, then re-render every row with padding; respect `>` (col span) and `\/` (row span) markers. | -| `nuwiki.table.moveColumn` | §12.5 | Swap column with neighbour (`dir: "left" \| "right"`). | Locate `TableNode` under cursor; for each row, swap `cells[col]` and `cells[col±1]`; re-emit. | -| `nuwiki.table.insert` | §12.5 | Insert blank table at cursor (`cols`, `rows`). | Pure string templating; align outputs to current indentation. | -| `nuwiki.link.normalize` | §12.5 | Convert word/selection to `[[wikilink]]`, add description if missing. | Inspect cursor selection; if a plain word, wrap in `[[…]]`; if already a wikilink, ensure `\|description` is present. | -| `nuwiki.link.pasteWikilink` | §12.5 | Paste current page name as an absolute wikilink. | One-line insert; page name from `index::page_name_from_uri`. | -| `nuwiki.link.pasteUrl` | §12.5 | Paste the corresponding HTML output URL. | Blocked on Phase 17 (`html_path` / `html_filename_parameterization` config). | -| `nuwiki.colorize` | §12.5 | Wrap range in colour tag per `color_tag_template`. | Blocked on Phase 17 (`color_dic` config) — until then the template isn't reachable. | +| Command | Status | Notes | +|---|---|---| +| `nuwiki.list.changeSymbol` | ✅ done | `ops::change_symbol_edit` + `parse_symbol` + `render_marker` (incl. alpha + roman variants). | +| `nuwiki.list.changeLevel` | ✅ done | ±2-space indent per delta; `whole_subtree` walks descendants; dedent clamps at column 0. | +| `nuwiki.list.renumber` | ✅ done | Re-sequences numeric markers in the current list, or every list when `whole_file = true`. | +| `nuwiki.list.removeDone` | ✅ done | Strips `[X]` / `[-]` items + their sublists; optional `position` scopes to one item. | +| `nuwiki.table.align` | ✅ done | Width-per-column pass + row re-emit. Re-inserts the dropped `|---|---|` separator after the header. | +| `nuwiki.table.moveColumn` | ✅ done | Swap column with neighbour (`dir: "left" \| "right"`); widths swap alongside. | +| `nuwiki.table.insert` | ✅ done | Pure templating — `cols × rows` blank cells + header separator. | +| `nuwiki.link.normalize` | ✅ done | Client-side word-wrap (Lua/VimL). Mirrors the `` two-step wrap step. | +| `nuwiki.link.pasteWikilink` | ✅ done | Server-side; inserts `[[]]` at cursor. | +| `nuwiki.link.pasteUrl` | ✅ done | Server-side; inserts `.html` (subdir-aware). | +| `nuwiki.colorize` | ✅ done | Client-side wrap in `` (matches vimwiki default template). | -#### Estimated sequencing - -- **Cluster A (list rewriters)** — `changeSymbol`, `changeLevel`, - `renumber`, `removeDone` share a list-walker pattern. Likely one - PR with a shared `ops::list_walker` helper and four command handlers. -- **Cluster B (table rewriters)** — `align`, `moveColumn`, `insert` - share a column-width pass and a row-by-row re-emitter. Likely a - second PR. -- **Cluster C (link helpers)** — `normalize` and `pasteWikilink` are - small and don't share much; can land independently. -- **Phase-17-blocked** — `pasteUrl` and `colorize` wait until the - HTML export commands land the necessary config keys. +`color_dic` integration with the `ColorNode` HTML renderer also +landed alongside `colorize` — the dict maps vimwiki colour-tag names +to CSS values; missing names fall back to `class="color-"`. ### 13.2 Phase 13 — `will_rename` / `will_delete` capability diff --git a/autoload/nuwiki/commands.vim b/autoload/nuwiki/commands.vim index 5fd507d..c1429da 100644 --- a/autoload/nuwiki/commands.vim +++ b/autoload/nuwiki/commands.vim @@ -559,8 +559,37 @@ function! nuwiki#commands#table_move_right() abort \ }] call s:exec('nuwiki.table.moveColumn', l:args) endfunction -function! nuwiki#commands#colorize(color) abort - call s:notify_deferred(':VimwikiColorize') +" `:VimwikiColorize {color}` — pure-VimL wrap as inline span tag, +" matching vimwiki's `%s` template. +function! nuwiki#commands#colorize(...) abort + let l:color = a:0 >= 1 ? a:1 : '' + if l:color ==# '' + let l:color = input('Colour: ') + endif + if l:color ==# '' + return + endif + let l:open_tag = '' + let l:close_tag = '' + let l:line = getline('.') + let l:col = col('.') + " Word boundaries around cursor (1-based). + let l:left = l:col + while l:left > 1 && strpart(l:line, l:left - 2, 1) =~# '[A-Za-z0-9_-]' + let l:left -= 1 + endwhile + let l:right = l:col + while l:right <= strlen(l:line) && strpart(l:line, l:right - 1, 1) =~# '[A-Za-z0-9_-]' + let l:right += 1 + endwhile + let l:word = strpart(l:line, l:left - 1, l:right - l:left) + if l:word ==# '' + return + endif + let l:new = strpart(l:line, 0, l:left - 1) + \ . l:open_tag . l:word . l:close_tag + \ . strpart(l:line, l:right - 1) + call setline('.', l:new) endfunction " §13.1 Cluster C — link helpers. diff --git a/crates/nuwiki-core/src/render/html.rs b/crates/nuwiki-core/src/render/html.rs index 2ac72c6..b54c756 100644 --- a/crates/nuwiki-core/src/render/html.rs +++ b/crates/nuwiki-core/src/render/html.rs @@ -36,6 +36,11 @@ pub struct HtmlRenderer { link_resolver: LinkResolver, template: Option, vars: HashMap, + /// `color_dic`-style override: vimwiki colour-tag names → CSS + /// values (`"red"` / `"#ffe119"` / `"oklch(…)"`). Unspecified + /// names fall through to the default `class="color-"` + /// rendering. Matches SPEC §12.11 `color_dic`. + colors: HashMap, } impl Default for HtmlRenderer { @@ -50,6 +55,7 @@ impl HtmlRenderer { link_resolver: Arc::new(default_link_resolver), template: None, vars: HashMap::new(), + colors: HashMap::new(), } } @@ -83,6 +89,14 @@ impl HtmlRenderer { self.vars = vars; self } + + /// Supply a `color_dic`: vimwiki colour-tag names mapped to their + /// CSS values. A name listed here is rendered as `style="color:V"`; + /// missing names fall through to `class="color-"`. + pub fn with_colors(mut self, colors: HashMap) -> Self { + self.colors = colors; + self + } } impl Renderer for HtmlRenderer { @@ -441,9 +455,15 @@ impl HtmlRenderer { } fn render_color(&self, n: &ColorNode, w: &mut dyn Write) -> io::Result<()> { - write!(w, "")?; + if let Some(css) = self.colors.get(&n.color) { + w.write_all(b"")?; + } else { + w.write_all(b"")?; + } self.render_inlines(&n.children, w)?; w.write_all(b"") } diff --git a/crates/nuwiki-lsp/src/config.rs b/crates/nuwiki-lsp/src/config.rs index 7429dec..aedfa09 100644 --- a/crates/nuwiki-lsp/src/config.rs +++ b/crates/nuwiki-lsp/src/config.rs @@ -63,6 +63,11 @@ pub struct HtmlConfig { /// Glob patterns (matched against the page name relative to root) /// to skip during `allToHtml*`. pub exclude_files: Vec, + /// `color_dic`: vimwiki colour-tag name → CSS value mapping used + /// by the HTML renderer (SPEC §12.11). Empty by default; the + /// renderer falls back to `class="color-"` when a name + /// isn't in the dict. + pub color_dic: std::collections::HashMap, } impl Default for HtmlConfig { @@ -77,6 +82,7 @@ impl Default for HtmlConfig { auto_export: false, html_filename_parameterization: false, exclude_files: Vec::new(), + color_dic: std::collections::HashMap::new(), } } } @@ -281,6 +287,8 @@ struct RawWiki { html_filename_parameterization: Option, #[serde(default)] exclude_files: Option>, + #[serde(default)] + color_dic: Option>, } impl From for WikiConfig { @@ -318,6 +326,9 @@ impl From for WikiConfig { if let Some(v) = r.exclude_files { html.exclude_files = v; } + if let Some(v) = r.color_dic { + html.color_dic = v; + } WikiConfig { name: r.name.unwrap_or(derived_name), root, diff --git a/crates/nuwiki-lsp/src/export.rs b/crates/nuwiki-lsp/src/export.rs index c3da357..ca1a8ca 100644 --- a/crates/nuwiki-lsp/src/export.rs +++ b/crates/nuwiki-lsp/src/export.rs @@ -220,6 +220,9 @@ pub fn render_page_html( r = r.with_template(t); } r = r.with_vars(vars); + if !cfg.color_dic.is_empty() { + r = r.with_colors(cfg.color_dic.clone()); + } r.render_to_string(doc) } diff --git a/crates/nuwiki-lsp/tests/phase17_colorize.rs b/crates/nuwiki-lsp/tests/phase17_colorize.rs new file mode 100644 index 0000000..c08085b --- /dev/null +++ b/crates/nuwiki-lsp/tests/phase17_colorize.rs @@ -0,0 +1,75 @@ +//! §13.1 §13.3 — `color_dic` → `HtmlRenderer` integration. +//! +//! Drives the renderer directly so we don't have to spin up the full +//! export pipeline. The end-to-end path (export command → renderer +//! with `cfg.color_dic` plumbed through) is integration-tested via the +//! Phase 17 export tests. + +use std::collections::HashMap; + +use nuwiki_core::ast::{ColorNode, DocumentNode, InlineNode, ParagraphNode, Span, TextNode}; +use nuwiki_core::render::{HtmlRenderer, Renderer}; +use nuwiki_lsp::config::config_from_json; + +fn doc_with_color(name: &str, text: &str) -> DocumentNode { + let span = Span::default(); + DocumentNode { + span, + metadata: Default::default(), + children: vec![nuwiki_core::ast::BlockNode::Paragraph(ParagraphNode { + span, + children: vec![InlineNode::Color(ColorNode { + span, + color: name.into(), + children: vec![InlineNode::Text(TextNode { + span, + content: text.into(), + })], + })], + })], + } +} + +#[test] +fn color_falls_back_to_class_when_dict_empty() { + let doc = doc_with_color("red", "hello"); + let r = HtmlRenderer::new(); + let out = r.render_to_string(&doc).unwrap(); + assert!(out.contains(r#""#), "got: {out}"); + assert!(out.contains("hello")); +} + +#[test] +fn color_dic_emits_inline_style_when_name_listed() { + let mut dict = HashMap::new(); + dict.insert("red".to_string(), "#ff0000".to_string()); + let doc = doc_with_color("red", "hello"); + let r = HtmlRenderer::new().with_colors(dict); + let out = r.render_to_string(&doc).unwrap(); + assert!( + out.contains(r#""#), + "got: {out}" + ); +} + +#[test] +fn unlisted_color_falls_back_even_with_dict_set() { + let mut dict = HashMap::new(); + dict.insert("red".to_string(), "#ff0000".to_string()); + let doc = doc_with_color("violet", "x"); + let r = HtmlRenderer::new().with_colors(dict); + let out = r.render_to_string(&doc).unwrap(); + assert!(out.contains(r#""#), "got: {out}"); +} + +#[test] +fn config_from_json_accepts_color_dic() { + let cfg = config_from_json(serde_json::json!({ + "wikis": [ + { "root": "/tmp/c", "color_dic": { "red": "red", "blue": "blue" } }, + ], + })); + let d = &cfg.wikis[0].html.color_dic; + assert_eq!(d.get("red").map(String::as_str), Some("red")); + assert_eq!(d.get("blue").map(String::as_str), Some("blue")); +} diff --git a/ftplugin/vimwiki.vim b/ftplugin/vimwiki.vim index 465c588..ec8f0f0 100644 --- a/ftplugin/vimwiki.vim +++ b/ftplugin/vimwiki.vim @@ -134,8 +134,8 @@ if !has('nvim') nnoremap wn :call nuwiki#commands#wiki_goto_page('') nnoremap wd :call nuwiki#commands#delete_file() nnoremap wr :call nuwiki#commands#rename_file() - nnoremap wc :echohl WarningMsgechom 'nuwiki: :VimwikiColorize not yet implemented — see SPEC §13.1'echohl None - xnoremap wc :echohl WarningMsgechom 'nuwiki: :VimwikiColorize not yet implemented — see SPEC §13.1'echohl None + nnoremap wc :call nuwiki#commands#colorize('') + xnoremap wc :call nuwiki#commands#colorize('') " Diary nav nnoremap :call nuwiki#commands#diary_next() diff --git a/lua/nuwiki/commands.lua b/lua/nuwiki/commands.lua index 6949fa9..aa65688 100644 --- a/lua/nuwiki/commands.lua +++ b/lua/nuwiki/commands.lua @@ -501,7 +501,61 @@ function M.table_move_column_right() exec('nuwiki.table.moveColumn', { p }) end -M.colorize = _not_yet(':VimwikiColorize') +--- `:VimwikiColorize {color}` — wrap the word (or visual selection) +--- under cursor in an inline span tag. Matches vimwiki's behaviour: +--- the template is `%s` with `%c` +--- replaced by the user-supplied colour and `%s` by the wrapped text. +--- Pure-Lua; no server roundtrip needed. +local function visual_selection_range() + local mode = vim.fn.visualmode() + if mode == '' then return nil end + local s = vim.api.nvim_buf_get_mark(0, '<') + local e = vim.api.nvim_buf_get_mark(0, '>') + if s[1] == 0 or e[1] == 0 then return nil end + return s[1], s[2], e[1], e[2] +end + +function M.colorize(color) + if not color or color == '' then + color = vim.fn.input('Colour: ') + if color == '' then return end + end + local sl, sc, el, ec = visual_selection_range() + local row = vim.api.nvim_win_get_cursor(0)[1] + local line = vim.api.nvim_get_current_line() + local open_tag = string.format('', color) + local close_tag = '' + if sl and sl == el then + -- Single-line visual selection. + local new_line = line:sub(1, sc) + .. open_tag + .. line:sub(sc + 1, ec + 1) + .. close_tag + .. line:sub(ec + 2) + vim.api.nvim_buf_set_lines(0, sl - 1, sl, false, { new_line }) + return + end + -- Fall back to the cword. + local cword = vim.fn.expand('') + if cword == '' then return end + local col = vim.api.nvim_win_get_cursor(0)[2] + local left = col + while left > 0 and line:sub(left, left):match('[%w_%-]') do + left = left - 1 + end + if not line:sub(left + 1, left + 1):match('[%w_%-]') then + left = left + 1 + end + local right = left + 1 + while right <= #line and line:sub(right, right):match('[%w_%-]') do + right = right + 1 + end + local before = line:sub(1, left) + local word = line:sub(left + 1, right - 1) + local after = line:sub(right) + local new_line = before .. open_tag .. word .. close_tag .. after + vim.api.nvim_buf_set_lines(0, row - 1, row, false, { new_line }) +end -- §13.1 Cluster C — link helpers. diff --git a/lua/nuwiki/keymaps.lua b/lua/nuwiki/keymaps.lua index 5975ce8..e22f651 100644 --- a/lua/nuwiki/keymaps.lua +++ b/lua/nuwiki/keymaps.lua @@ -189,8 +189,10 @@ function M.attach(bufnr, mappings) map('n', 'wn', cmd.wiki_goto_page, { desc = 'nuwiki: goto page' }, bufnr) map('n', 'wd', cmd.delete_file, { desc = 'nuwiki: delete page' }, bufnr) map('n', 'wr', cmd.rename_file, { desc = 'nuwiki: rename page' }, bufnr) - map('n', 'wc', deferred(':VimwikiColorize'), { desc = 'nuwiki: colorize (deferred)' }, bufnr) - map('x', 'wc', deferred(':VimwikiColorize'), { desc = 'nuwiki: colorize (deferred)' }, bufnr) + map('n', 'wc', function() cmd.colorize() end, + { desc = 'nuwiki: wrap word in colour span' }, bufnr) + map('x', 'wc', function() cmd.colorize() end, + { desc = 'nuwiki: wrap selection in colour span' }, bufnr) end -- ===== Diary nav =====