Compare commits
35 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 0df72e52ea | |||
| 4a717bcb31 | |||
| f1494dd2fc | |||
| a653903dba | |||
| e0f806d307 | |||
| 5dd02e7b9e | |||
| 8fdfa9e256 | |||
| 59494e9a86 | |||
| e1d734a434 | |||
| fcce5621a0 | |||
| 3e8c534859 | |||
| 0d1a73a3ba | |||
| e6f400b370 | |||
| 96d53dddf3 | |||
| c144cbbb55 | |||
| 3c1ed48a2f | |||
| 34a0607e7a | |||
| daee0f1902 | |||
| f0c51fbfcc | |||
| 94cb58064d | |||
| 9d6d28a1b6 | |||
| 346e8b0de6 | |||
| ac14cdd838 | |||
| a4643bdacb | |||
| 9d89e01ed8 | |||
| fc0d74bfbe | |||
| f3d8af5f23 | |||
| bd729d513d | |||
| 5a936e4f96 | |||
| 32cf4508de | |||
| 7e757c6a7f | |||
| a5a07768d6 | |||
| 08ebdc6f9b | |||
| b77f4b1ced | |||
| c9d75aeb1f |
@@ -120,3 +120,9 @@ jobs:
|
||||
run: ./development/tests/test-calendar-vim.sh
|
||||
- name: run Vim vars-shim harness
|
||||
run: ./development/tests/test-vars-vim.sh
|
||||
- name: run Vim vimwiki-config compat harness
|
||||
run: ./development/tests/test-vimwiki-compat-vim.sh
|
||||
- name: run Neovim global-shorthand harness
|
||||
run: ./development/tests/test-global-shorthand.sh
|
||||
- name: run Vim coc-registration harness
|
||||
run: ./development/tests/test-coc-register-vim.sh
|
||||
|
||||
+113
-22
@@ -1,9 +1,17 @@
|
||||
name: Release
|
||||
|
||||
# Releases are cut from the Actions UI: "Run workflow" → enter the version
|
||||
# (e.g. 0.4.0). The `prepare` job stamps that version into every hardcoded
|
||||
# spot (scripts/set-version.sh), commits "chore(release): X.Y.Z", and pushes
|
||||
# the vX.Y.Z tag. The build matrix + release job then run off that tag. No
|
||||
# more hand-editing versions across Cargo.toml/plugin/nuwiki.vim.
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- 'v*'
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
version:
|
||||
description: 'Release version, no leading v (e.g. 0.4.0)'
|
||||
required: true
|
||||
type: string
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
@@ -14,8 +22,77 @@ env:
|
||||
RUSTFLAGS: -D warnings
|
||||
|
||||
jobs:
|
||||
prepare:
|
||||
name: bump + tag
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
version: ${{ steps.stamp.outputs.version }}
|
||||
tag: ${{ steps.stamp.outputs.tag }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
# Push the release commit + tag back with a write-capable token.
|
||||
token: ${{ secrets.RELEASE_TOKEN }}
|
||||
|
||||
- uses: dtolnay/rust-toolchain@1.83
|
||||
|
||||
- name: Cache cargo state
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: |
|
||||
~/.cargo/registry
|
||||
~/.cargo/git
|
||||
target
|
||||
key: ${{ runner.os }}-cargo-prepare-${{ hashFiles('**/Cargo.lock') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-cargo-prepare-
|
||||
|
||||
- name: Validate version + stamp
|
||||
id: stamp
|
||||
env:
|
||||
VERSION: ${{ inputs.version }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
ver="${VERSION#v}" # tolerate a leading v
|
||||
if ! printf '%s' "$ver" | grep -Eq '^[0-9]+\.[0-9]+\.[0-9]+([-.][0-9A-Za-z.-]+)?$'; then
|
||||
echo "::error::'$ver' is not a semver version (expected e.g. 0.4.0)"
|
||||
exit 1
|
||||
fi
|
||||
tag="v$ver"
|
||||
if git rev-parse -q --verify "refs/tags/$tag" >/dev/null \
|
||||
|| git ls-remote --exit-code --tags origin "$tag" >/dev/null 2>&1; then
|
||||
echo "::error::tag $tag already exists"
|
||||
exit 1
|
||||
fi
|
||||
bash scripts/set-version.sh "$ver"
|
||||
echo "version=$ver" >> "$GITHUB_OUTPUT"
|
||||
echo "tag=$tag" >> "$GITHUB_OUTPUT"
|
||||
|
||||
# Gate: never tag code that doesn't build/test. If this fails nothing
|
||||
# is committed or pushed, so the release simply doesn't happen.
|
||||
- name: Test
|
||||
run: cargo test --workspace
|
||||
|
||||
- name: Commit, tag, push
|
||||
env:
|
||||
VER: ${{ steps.stamp.outputs.version }}
|
||||
TAG: ${{ steps.stamp.outputs.tag }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
git config user.name "gitea-actions"
|
||||
git config user.email "gitea-actions@users.noreply.code.gfran.co"
|
||||
git add Cargo.toml Cargo.lock \
|
||||
crates/nuwiki-lsp/Cargo.toml crates/nuwiki-ls/Cargo.toml \
|
||||
plugin/nuwiki.vim
|
||||
git commit -m "chore(release): $VER"
|
||||
git tag -a "$TAG" -m "nuwiki $VER"
|
||||
git push origin "HEAD:${GITHUB_REF_NAME}"
|
||||
git push origin "$TAG"
|
||||
|
||||
build:
|
||||
name: build ${{ matrix.target }}
|
||||
needs: prepare
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
fail-fast: false
|
||||
@@ -38,6 +115,9 @@ jobs:
|
||||
rustflags: "-D warnings -C linker=rust-lld -C link-self-contained=yes"
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
# Build the just-tagged commit, not the branch tip.
|
||||
ref: ${{ needs.prepare.outputs.tag }}
|
||||
|
||||
- uses: dtolnay/rust-toolchain@1.83
|
||||
with:
|
||||
@@ -72,15 +152,19 @@ jobs:
|
||||
|
||||
- name: Package archive
|
||||
id: package
|
||||
env:
|
||||
VERSION: ${{ needs.prepare.outputs.version }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
version="${GITHUB_REF_NAME#v}"
|
||||
archive="nuwiki-ls-${version}-${{ matrix.target }}.tar.gz"
|
||||
archive="nuwiki-ls-${VERSION}-${{ matrix.target }}.tar.gz"
|
||||
tar -czf "$archive" -C "target/${{ matrix.target }}/release" nuwiki-ls
|
||||
echo "archive=$archive" >> "$GITHUB_OUTPUT"
|
||||
# Use a stable name without version so /releases/latest/download/nuwiki-ls-{target}.tar.gz always resolves.
|
||||
stable="nuwiki-ls-${{ matrix.target }}.tar.gz"
|
||||
mv "$archive" "$stable"
|
||||
echo "archive=$stable" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Upload build artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
uses: actions/upload-artifact@v3
|
||||
with:
|
||||
name: nuwiki-ls-${{ matrix.target }}
|
||||
path: ${{ steps.package.outputs.archive }}
|
||||
@@ -88,16 +172,20 @@ jobs:
|
||||
|
||||
release:
|
||||
name: gitea release
|
||||
needs: build
|
||||
needs: [prepare, build]
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
ref: ${{ needs.prepare.outputs.tag }}
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Download all build artifacts
|
||||
# upload-artifact@v4 + download-artifact@v4 with merge-multiple
|
||||
# flattens all artifacts into the download path directly.
|
||||
uses: actions/download-artifact@v4
|
||||
# download-artifact@v3 nests each artifact under its own dir
|
||||
# (artifacts/<artifact-name>/<file>), so recurse.
|
||||
uses: actions/download-artifact@v3
|
||||
with:
|
||||
path: ./artifacts
|
||||
merge-multiple: true
|
||||
|
||||
- name: Ensure jq + curl
|
||||
run: |
|
||||
@@ -110,18 +198,20 @@ jobs:
|
||||
|
||||
- name: Generate release notes
|
||||
id: notes
|
||||
env:
|
||||
TAG: ${{ needs.prepare.outputs.tag }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
# Get commits since the previous tag (or the first commit if this is the first release).
|
||||
prev_tag=$(git tag --sort=-version:refname | awk "NR==2" || echo "")
|
||||
git fetch --tags --force origin >/dev/null 2>&1 || true
|
||||
# Previous tag = newest tag that isn't the one we just cut.
|
||||
prev_tag=$(git tag --sort=-version:refname | grep -vxF "$TAG" | head -n1 || echo "")
|
||||
if [ -n "$prev_tag" ]; then
|
||||
log=$(git log --oneline "$prev_tag"..HEAD)
|
||||
log=$(git log --oneline "$prev_tag..$TAG")
|
||||
else
|
||||
log=$(git log --oneline --max-parents=0..HEAD)
|
||||
log=$(git log --oneline "$TAG")
|
||||
fi
|
||||
# Write notes to a file for later use.
|
||||
cat > release-notes.txt <<EOF
|
||||
**Full Changelog**: ${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/compare/${prev_tag:-$(git rev-list --max-parents=0 HEAD)}...${GITHUB_REF_NAME}
|
||||
**Full Changelog**: ${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/compare/${prev_tag:-$(git rev-list --max-parents=0 HEAD)}...${TAG}
|
||||
|
||||
**Changes**:
|
||||
$log
|
||||
@@ -133,7 +223,7 @@ jobs:
|
||||
RELEASE_TOKEN: ${{ secrets.RELEASE_TOKEN }}
|
||||
GITEA_SERVER: ${{ github.server_url }}
|
||||
REPO: ${{ github.repository }}
|
||||
TAG: ${{ github.ref_name }}
|
||||
TAG: ${{ needs.prepare.outputs.tag }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if [ -z "${RELEASE_TOKEN:-}" ]; then
|
||||
@@ -147,7 +237,7 @@ jobs:
|
||||
existing=$(curl --fail --silent --show-error -o /dev/null -w "%{http_code}" \
|
||||
-H "Authorization: token $RELEASE_TOKEN" \
|
||||
"$GITEA_SERVER/api/v1/repos/$REPO/releases/tags/$TAG" \
|
||||
|| true)
|
||||
2>/dev/null || true)
|
||||
if [ "$existing" = "200" ]; then
|
||||
echo "Release for $TAG already exists — fetching existing id"
|
||||
release_id=$(curl --fail --silent --show-error \
|
||||
@@ -155,8 +245,9 @@ jobs:
|
||||
"$GITEA_SERVER/api/v1/repos/$REPO/releases/tags/$TAG" \
|
||||
| jq -r '.id')
|
||||
else
|
||||
payload=$(jq -n --arg tag "$TAG" --arg name "$TAG" \
|
||||
'{tag_name: $tag, name: $name, draft: false, prerelease: false}')
|
||||
notes=$(cat release-notes.txt 2>/dev/null || echo "")
|
||||
payload=$(jq -n --arg tag "$TAG" --arg name "$TAG" --arg body "$notes" \
|
||||
'{tag_name: $tag, name: $name, body: $body, draft: false, prerelease: false}')
|
||||
release_id=$(curl --fail --silent --show-error \
|
||||
-H "Authorization: token $RELEASE_TOKEN" \
|
||||
-H "Content-Type: application/json" \
|
||||
|
||||
Generated
+3
-3
@@ -376,11 +376,11 @@ checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79"
|
||||
|
||||
[[package]]
|
||||
name = "nuwiki-core"
|
||||
version = "0.1.0"
|
||||
version = "0.4.2"
|
||||
|
||||
[[package]]
|
||||
name = "nuwiki-ls"
|
||||
version = "0.1.0"
|
||||
version = "0.4.2"
|
||||
dependencies = [
|
||||
"nuwiki-lsp",
|
||||
"tokio",
|
||||
@@ -388,7 +388,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "nuwiki-lsp"
|
||||
version = "0.1.0"
|
||||
version = "0.4.2"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"dashmap 6.1.0",
|
||||
|
||||
+1
-1
@@ -7,7 +7,7 @@ members = [
|
||||
]
|
||||
|
||||
[workspace.package]
|
||||
version = "0.1.0"
|
||||
version = "0.4.2"
|
||||
edition = "2021"
|
||||
rust-version = "1.83"
|
||||
authors = ["Gabriel Fróes Franco <gffranco@gmail.com>"]
|
||||
|
||||
@@ -82,9 +82,9 @@ Neovim) instead of relying on a plugin-manager build hook.
|
||||
Older releases fall back to a `vim.lsp.start` autocmd but aren't
|
||||
officially supported.
|
||||
- **Vim 9+** — needs a third-party LSP client:
|
||||
[`vim-lsp`](https://github.com/prabirshrestha/vim-lsp) (recommended,
|
||||
auto-registered) or [`coc.nvim`](https://github.com/neoclide/coc.nvim)
|
||||
(one-time `coc-settings.json` entry — see [Vim LSP client
|
||||
[`vim-lsp`](https://github.com/prabirshrestha/vim-lsp) or
|
||||
[`coc.nvim`](https://github.com/neoclide/coc.nvim) — both
|
||||
auto-registered, no manual config (see [Vim LSP client
|
||||
setup](#vim-lsp-client-setup)).
|
||||
- **Rust toolchain (1.83+ stable)** — only needed when building the
|
||||
binary from source; pre-built release downloads skip this.
|
||||
@@ -145,10 +145,18 @@ auto-enables it. Your wiki settings (`g:nuwiki_wiki_root`,
|
||||
`g:nuwiki_wikis`, …) are forwarded as the server's initialization
|
||||
options.
|
||||
|
||||
**coc.nvim** — [`coc.nvim`](https://github.com/neoclide/coc.nvim) reads
|
||||
its server list from `coc-settings.json`, which the plugin can't edit
|
||||
on your behalf, so add the entry once yourself (open it with
|
||||
`:CocConfig`):
|
||||
**coc.nvim** — also zero configuration. nuwiki registers itself with
|
||||
[`coc.nvim`](https://github.com/neoclide/coc.nvim) programmatically
|
||||
(`coc#config('languageserver.nuwiki', …)`) when the first `.wiki` buffer
|
||||
loads, forwarding the same wiki settings (`g:nuwiki_wiki_root`,
|
||||
`g:nuwiki_wikis`, your `g:vimwiki_*` config, the global shorthand, …) as
|
||||
the server's initialization options. **No `coc-settings.json`
|
||||
`languageserver` entry needed** — change your wiki config and reload; the
|
||||
new settings are picked up.
|
||||
|
||||
To manage the entry yourself instead, set `let g:nuwiki_no_coc_register = 1`
|
||||
and add it to `coc-settings.json` manually (nuwiki then just prints the
|
||||
snippet on first load):
|
||||
|
||||
```json
|
||||
{
|
||||
@@ -337,6 +345,14 @@ The `<Leader>w` prefix in the `wiki_prefix`, `links`, and `html_export` rows is
|
||||
|
||||
#### Per-wiki keys (`wikis[]`)
|
||||
|
||||
Most display/generation keys below can also be set **once at the top level**
|
||||
as a default for every wiki (vimwiki-style global shorthand) — via `setup({ … })`
|
||||
or `g:nuwiki_<key>` — and a per-wiki value overrides it. The keys that honour
|
||||
this are `toc_header`, `toc_header_level`, `toc_link_format`, `links_header`,
|
||||
`links_header_level`, `tags_header`, `tags_header_level`,
|
||||
`html_header_numbering`, `html_header_numbering_sym`, `links_space_char`,
|
||||
`list_margin`, `listsyms`, `listsym_rejected`, and the `auto_*` toggles.
|
||||
|
||||
| Key | Type | Default | Accepted values |
|
||||
|-----|------|---------|-----------------|
|
||||
| `name` | string | — | any label |
|
||||
@@ -660,8 +676,20 @@ match vimwiki. To migrate:
|
||||
|
||||
1. Drop the original `vimwiki` plugin from your config.
|
||||
2. Install nuwiki.
|
||||
3. Point `wiki_root` (or each `wikis[i].root`) at your existing
|
||||
directory.
|
||||
3. Keep your existing config, or point `wiki_root` / `wikis[i].root` at
|
||||
your directory.
|
||||
|
||||
**Existing `g:vimwiki_list` config works as-is (Vim & Neovim).** When you
|
||||
haven't configured nuwiki natively (`g:nuwiki_wikis` / `setup()` / `g:nuwiki_*`),
|
||||
both clients read your upstream `g:vimwiki_list` and `g:vimwiki_*` globals and
|
||||
translate them: each wiki's `path`/`path_html`/`template_*`/`auto_export`
|
||||
plus globals like `toc_header_level`, `html_header_numbering`,
|
||||
`html_header_numbering_sym`, `links_space_char`, and `list_margin`.
|
||||
Native config always takes precedence. (Settings nuwiki implements
|
||||
differently — see [`known-issues.md`](./known-issues.md) — are ignored.)
|
||||
On Neovim with lazy.nvim, swap the `vimwiki` plugin spec for nuwiki and
|
||||
call `require('nuwiki').setup()` — your `vim.g.vimwiki_list` is picked up
|
||||
unchanged.
|
||||
|
||||
Existing pages, diary entries, tags, and templates work without
|
||||
modification. The first workspace scan after launch may show an empty
|
||||
|
||||
@@ -588,14 +588,13 @@ endfunction
|
||||
" open their wiki from any buffer. Files are opened directly from config;
|
||||
" the LSP auto-starts via the FileType autocmd once the buffer loads.
|
||||
|
||||
" Return a dict with {root, ext, idx, diary_rel, diary_idx} for wiki #n
|
||||
" (0-based). Prefers g:nuwiki_wikis[n]; falls back to scalar g:nuwiki_*
|
||||
" vars and finally to built-in defaults.
|
||||
" Return a dict with {root, ext, idx, diary_rel, diary_idx, space} for wiki #n
|
||||
" (0-based). Resolves through nuwiki#config#wikis() — so g:nuwiki_wikis AND an
|
||||
" upstream g:vimwiki_list both work — and falls back to the scalar g:nuwiki_*
|
||||
" vars (single-wiki shorthand) and finally built-in defaults.
|
||||
function! s:wiki_cfg(n) abort
|
||||
let l:w = {}
|
||||
if exists('g:nuwiki_wikis') && len(g:nuwiki_wikis) > a:n
|
||||
let l:w = g:nuwiki_wikis[a:n]
|
||||
endif
|
||||
let l:wikis = nuwiki#config#wikis()
|
||||
let l:w = (a:n >= 0 && a:n < len(l:wikis)) ? l:wikis[a:n] : {}
|
||||
let l:root = expand(get(l:w, 'root',
|
||||
\ get(g:, 'nuwiki_wiki_root', '~/vimwiki')))
|
||||
let l:ext = get(l:w, 'file_extension',
|
||||
@@ -612,14 +611,15 @@ function! s:wiki_cfg(n) abort
|
||||
endfunction
|
||||
|
||||
" Build the list of configured wikis as {name, root, ext, idx, diary_rel,
|
||||
" diary_idx} dicts, straight from config — no LSP round-trip. Drives the wiki
|
||||
" picker so it works before any wiki buffer (and therefore the server) exists.
|
||||
" Falls back to a single entry from the scalar `g:nuwiki_wiki_root` config.
|
||||
" diary_idx, space} dicts, straight from config — no LSP round-trip. Drives the
|
||||
" wiki picker so it works before any wiki buffer (and therefore the server)
|
||||
" exists. Falls back to a single entry from the scalar `g:nuwiki_wiki_root`.
|
||||
function! nuwiki#commands#wiki_list() abort
|
||||
let l:wikis = nuwiki#config#wikis()
|
||||
let l:list = []
|
||||
if exists('g:nuwiki_wikis') && !empty(g:nuwiki_wikis)
|
||||
if !empty(l:wikis)
|
||||
let l:n = 0
|
||||
for l:w in g:nuwiki_wikis
|
||||
for l:w in l:wikis
|
||||
let l:c = s:wiki_cfg(l:n)
|
||||
let l:c['name'] = get(l:w, 'name', fnamemodify(l:c.root, ':t'))
|
||||
call add(l:list, l:c)
|
||||
@@ -725,18 +725,23 @@ endfunction
|
||||
" ===== Generation + workspace =====
|
||||
|
||||
function! nuwiki#commands#toc_generate() abort
|
||||
call s:exec('nuwiki.toc.generate', [{'uri': s:buf_uri()}])
|
||||
" Send the cursor line (0-based) so a fresh TOC is inserted there.
|
||||
call s:exec('nuwiki.toc.generate',
|
||||
\ [{'uri': s:buf_uri(), 'line': line('.') - 1}])
|
||||
endfunction
|
||||
" `:VimwikiGenerateLinks [rel_path]` — with no arg, list every page; with a
|
||||
" rel-path arg, scope the generated links to that subtree (server-side
|
||||
" `generateForPath`). Matches upstream's optional path argument.
|
||||
function! nuwiki#commands#links_generate(...) abort
|
||||
let l:path = a:0 >= 1 ? a:1 : ''
|
||||
" Cursor line (0-based) so a fresh section is inserted there.
|
||||
let l:line = line('.') - 1
|
||||
if l:path !=# ''
|
||||
call s:exec('nuwiki.links.generateForPath',
|
||||
\ [{'uri': s:buf_uri(), 'path': l:path}])
|
||||
\ [{'uri': s:buf_uri(), 'path': l:path, 'line': l:line}])
|
||||
else
|
||||
call s:exec('nuwiki.links.generate', [{'uri': s:buf_uri()}])
|
||||
call s:exec('nuwiki.links.generate',
|
||||
\ [{'uri': s:buf_uri(), 'line': l:line}])
|
||||
endif
|
||||
endfunction
|
||||
function! nuwiki#commands#check_links(...) abort
|
||||
@@ -798,7 +803,8 @@ function! nuwiki#commands#tags_search(query) abort
|
||||
endfunction
|
||||
|
||||
function! nuwiki#commands#tags_generate_links(tag) abort
|
||||
let l:args = { 'uri': s:buf_uri() }
|
||||
" Cursor line (0-based) so a fresh section is inserted there.
|
||||
let l:args = { 'uri': s:buf_uri(), 'line': line('.') - 1 }
|
||||
if a:tag !=# ''
|
||||
let l:args['tag'] = a:tag
|
||||
endif
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
" autoload/nuwiki/config.vim — single source of truth for the configured wiki
|
||||
" list, shared by the LSP payload (lsp.vim) and the buffer-side commands
|
||||
" (commands.vim, diary.vim, complete.vim).
|
||||
"
|
||||
" Resolves wikis from g:nuwiki_wikis (native) or, when that's unset, an upstream
|
||||
" g:vimwiki_list (drop-in compatibility), translating upstream key names and
|
||||
" folding the global scalar defaults (g:vimwiki_* / g:nuwiki_*) into each entry.
|
||||
" Everything downstream — `<Leader>ww`, link resolution, the server payload —
|
||||
" then sees the same wikis.
|
||||
|
||||
" Per-wiki dict keys: upstream `g:vimwiki_list[i]` key -> nuwiki/server key.
|
||||
let s:vimwiki_wiki_map = {
|
||||
\ 'path': 'root',
|
||||
\ 'path_html': 'html_path',
|
||||
\ 'template_path': 'template_path',
|
||||
\ 'template_default': 'template_default',
|
||||
\ 'template_ext': 'template_ext',
|
||||
\ 'css_name': 'css_name',
|
||||
\ 'auto_export': 'auto_export',
|
||||
\ 'auto_toc': 'auto_toc',
|
||||
\ 'syntax': 'syntax',
|
||||
\ 'ext': 'file_extension',
|
||||
\ 'index': 'index',
|
||||
\ 'diary_rel_path': 'diary_rel_path',
|
||||
\ 'diary_index': 'diary_index',
|
||||
\ 'name': 'name',
|
||||
\ }
|
||||
|
||||
" Global scalar settings vimwiki applies to every wiki (via wikilocal
|
||||
" defaults): upstream suffix (`g:vimwiki_<suffix>`) -> nuwiki/server key.
|
||||
let s:scalar_global_map = {
|
||||
\ 'toc_header': 'toc_header',
|
||||
\ 'toc_header_level': 'toc_header_level',
|
||||
\ 'toc_link_format': 'toc_link_format',
|
||||
\ 'links_header': 'links_header',
|
||||
\ 'links_header_level': 'links_header_level',
|
||||
\ 'tags_header': 'tags_header',
|
||||
\ 'tags_header_level': 'tags_header_level',
|
||||
\ 'html_header_numbering': 'html_header_numbering',
|
||||
\ 'html_header_numbering_sym': 'html_header_numbering_sym',
|
||||
\ 'links_space_char': 'links_space_char',
|
||||
\ 'list_margin': 'list_margin',
|
||||
\ 'listsyms': 'listsyms',
|
||||
\ 'listsym_rejected': 'listsym_rejected',
|
||||
\ 'auto_export': 'auto_export',
|
||||
\ 'auto_toc': 'auto_toc',
|
||||
\ 'auto_generate_links': 'auto_generate_links',
|
||||
\ 'auto_generate_tags': 'auto_generate_tags',
|
||||
\ 'auto_diary_index': 'auto_diary_index',
|
||||
\ }
|
||||
|
||||
" Resolve the per-wiki scalar globals: vimwiki values first (compat), then
|
||||
" nuwiki-native `g:nuwiki_<key>` overrides on top.
|
||||
function! nuwiki#config#scalar_globals() abort
|
||||
let l:g = {}
|
||||
for [l:src, l:dst] in items(s:scalar_global_map)
|
||||
if exists('g:vimwiki_' . l:src)
|
||||
let l:g[l:dst] = get(g:, 'vimwiki_' . l:src)
|
||||
endif
|
||||
endfor
|
||||
for l:dst in values(s:scalar_global_map)
|
||||
if exists('g:nuwiki_' . l:dst)
|
||||
let l:g[l:dst] = get(g:, 'nuwiki_' . l:dst)
|
||||
endif
|
||||
endfor
|
||||
return l:g
|
||||
endfunction
|
||||
|
||||
" Translate an upstream g:vimwiki_list into nuwiki's wiki list, folding the
|
||||
" given global scalars into each entry. Returns [] when unset/malformed.
|
||||
function! s:wikis_from_vimwiki(globals) abort
|
||||
if !exists('g:vimwiki_list') || type(g:vimwiki_list) != type([]) || empty(g:vimwiki_list)
|
||||
return []
|
||||
endif
|
||||
let l:wikis = []
|
||||
for l:vw in g:vimwiki_list
|
||||
if type(l:vw) != type({}) | continue | endif
|
||||
let l:w = copy(a:globals)
|
||||
for [l:src, l:dst] in items(s:vimwiki_wiki_map)
|
||||
if has_key(l:vw, l:src)
|
||||
let l:w[l:dst] = l:vw[l:src]
|
||||
endif
|
||||
endfor
|
||||
if has_key(l:w, 'root')
|
||||
call add(l:wikis, l:w)
|
||||
endif
|
||||
endfor
|
||||
return l:wikis
|
||||
endfunction
|
||||
|
||||
" The configured wikis as normalized dicts (nuwiki/server key names), with the
|
||||
" global scalar defaults folded in (per-wiki value wins). Priority:
|
||||
" 1. g:nuwiki_wikis (native)
|
||||
" 2. g:vimwiki_list (upstream drop-in)
|
||||
" Returns [] when neither is set — callers fall back to the single-wiki
|
||||
" shorthand (g:nuwiki_wiki_root).
|
||||
function! nuwiki#config#wikis() abort
|
||||
let l:globals = nuwiki#config#scalar_globals()
|
||||
if exists('g:nuwiki_wikis') && !empty(g:nuwiki_wikis)
|
||||
return map(copy(g:nuwiki_wikis), {_, w -> extend(copy(l:globals), w, 'force')})
|
||||
endif
|
||||
return s:wikis_from_vimwiki(l:globals)
|
||||
endfunction
|
||||
+36
-10
@@ -3,6 +3,33 @@
|
||||
|
||||
" ===== Calendar-Vim Integration =====
|
||||
|
||||
" The wiki index (0-based) of the most recently entered wiki buffer. The
|
||||
" calendar shows / creates diary entries for this wiki, so opening the calendar
|
||||
" while editing `ifood_wiki` diaries into ifood_wiki — not always the first
|
||||
" wiki. Updated by the BufEnter autocmd in plugin/nuwiki.vim; defaults to 0.
|
||||
" (The LSP-driven diary commands already follow the current buffer via its URI;
|
||||
" this mirrors that for the VimL calendar-vim callbacks.)
|
||||
let s:current_wiki_idx = 0
|
||||
|
||||
" Record the wiki that owns `file` as the current one. Called on entering a
|
||||
" wiki buffer. No-op when the file is under no configured wiki.
|
||||
function! nuwiki#diary#track_wiki(file) abort
|
||||
let l:file = fnamemodify(a:file, ':p')
|
||||
if empty(l:file)
|
||||
return
|
||||
endif
|
||||
let l:n = 0
|
||||
for l:w in nuwiki#commands#wiki_list()
|
||||
let l:root = fnamemodify(expand(l:w.root), ':p')
|
||||
if l:root[len(l:root) - 1:] !=# '/' | let l:root .= '/' | endif
|
||||
if l:file[: len(l:root) - 1] ==# l:root
|
||||
let s:current_wiki_idx = l:n
|
||||
return
|
||||
endif
|
||||
let l:n += 1
|
||||
endfor
|
||||
endfunction
|
||||
|
||||
" Function: nuwiki#diary#calendar_action(day, month, year, week, dir)
|
||||
" Called by calendar-vim when user presses Enter on a date.
|
||||
" day/month/year are numeric integers; dir is 'V' for a vertical split (any
|
||||
@@ -12,8 +39,8 @@ function! nuwiki#diary#calendar_action(day, month, year, week, dir) abort
|
||||
" Build YYYY-MM-DD from the parts passed by calendar-vim
|
||||
let l:date_str = printf('%04d-%02d-%02d', a:year, a:month, a:day)
|
||||
|
||||
" Get wiki configuration for the first wiki (index 0)
|
||||
let l:c = s:wiki_cfg(0)
|
||||
" Wiki of the buffer the calendar was opened from (falls back to the first).
|
||||
let l:c = s:wiki_cfg(s:current_wiki_idx)
|
||||
|
||||
" Construct file path: {root}/{diary_rel}/{YYYY-MM-DD}.{ext}
|
||||
let l:file_path = l:c.root . '/' . l:c.diary_rel . '/' . l:date_str . l:c.ext
|
||||
@@ -62,8 +89,8 @@ function! nuwiki#diary#calendar_sign(day, month, year) abort
|
||||
" Build YYYY-MM-DD from the parts passed by calendar-vim
|
||||
let l:date_str = printf('%04d-%02d-%02d', a:year, a:month, a:day)
|
||||
|
||||
" Get wiki configuration for the first wiki (index 0)
|
||||
let l:c = s:wiki_cfg(0)
|
||||
" Wiki of the buffer the calendar was opened from (falls back to the first).
|
||||
let l:c = s:wiki_cfg(s:current_wiki_idx)
|
||||
|
||||
" Construct expected file path
|
||||
let l:file_path = l:c.root . '/' . l:c.diary_rel . '/' . l:date_str . l:c.ext
|
||||
@@ -75,13 +102,12 @@ function! nuwiki#diary#calendar_sign(day, month, year) abort
|
||||
return ''
|
||||
endfunction
|
||||
|
||||
" Helper: get wiki configuration for wiki n (0-based).
|
||||
" Mirrors the same logic used by nuwiki#commands#open_diary_path etc.
|
||||
" Helper: get wiki configuration for wiki n (0-based). Resolves through
|
||||
" nuwiki#config#wikis() so g:nuwiki_wikis AND an upstream g:vimwiki_list both
|
||||
" work, falling back to the scalar g:nuwiki_* single-wiki shorthand.
|
||||
function! s:wiki_cfg(n) abort
|
||||
let l:w = {}
|
||||
if exists('g:nuwiki_wikis') && len(g:nuwiki_wikis) > a:n
|
||||
let l:w = g:nuwiki_wikis[a:n]
|
||||
endif
|
||||
let l:wikis = nuwiki#config#wikis()
|
||||
let l:w = (a:n >= 0 && a:n < len(l:wikis)) ? l:wikis[a:n] : {}
|
||||
let l:root = expand(get(l:w, 'root',
|
||||
\ get(g:, 'nuwiki_wiki_root', '~/vimwiki')))
|
||||
let l:ext = get(l:w, 'file_extension',
|
||||
|
||||
+71
-22
@@ -45,22 +45,7 @@ function! nuwiki#lsp#start() abort
|
||||
endif
|
||||
|
||||
if exists(':CocConfig') == 2 || exists('*coc#config')
|
||||
" coc.nvim — coc reads its server list from coc-settings.json.
|
||||
" We can't inject it dynamically without owning the file, so just point
|
||||
" the user at the snippet. `initializationOptions` carries wiki_root etc.
|
||||
" to the server exactly like the vim-lsp registration above — without it
|
||||
" the server resolves links against its default root, so existing pages
|
||||
" look broken and follow-to-create lands in the wrong directory.
|
||||
echohl WarningMsg
|
||||
echom 'nuwiki: coc.nvim detected. Add this to your coc-settings.json:'
|
||||
echom ' "languageserver": {'
|
||||
echom ' "nuwiki": {'
|
||||
echom ' "command": "' . l:bin . '",'
|
||||
echom ' "filetypes": ["vimwiki"],'
|
||||
echom ' "initializationOptions": ' . json_encode(s:settings())
|
||||
echom ' }'
|
||||
echom ' }'
|
||||
echohl None
|
||||
call s:register_with_coc(l:bin)
|
||||
return
|
||||
endif
|
||||
|
||||
@@ -71,6 +56,66 @@ function! nuwiki#lsp#start() abort
|
||||
echohl None
|
||||
endfunction
|
||||
|
||||
" ===== coc.nvim integration =====
|
||||
"
|
||||
" Register the nuwiki language server with coc *programmatically* via
|
||||
" coc#config, so users don't have to hand-maintain a coc-settings.json entry
|
||||
" per wiki. The config (wiki roots, per-wiki keys, the vimwiki/nuwiki global
|
||||
" shorthand) comes from s:settings() — the same payload the vim-lsp path sends.
|
||||
" coc reacts to the `languageserver` config change, registers the server, and
|
||||
" starts it for the open vimwiki buffer.
|
||||
"
|
||||
" Opt out with `let g:nuwiki_no_coc_register = 1` to manage the entry in
|
||||
" coc-settings.json yourself (we then just print the snippet).
|
||||
function! s:register_with_coc(bin) abort
|
||||
if get(g:, 'nuwiki_no_coc_register', 0)
|
||||
call s:print_coc_snippet(a:bin)
|
||||
return
|
||||
endif
|
||||
let s:coc_bin = a:bin
|
||||
if get(g:, 'coc_service_initialized', 0)
|
||||
call s:coc_register()
|
||||
else
|
||||
" coc hasn't finished starting its node service yet — defer until it has,
|
||||
" otherwise the updateConfig notification is sent to a service that isn't
|
||||
" listening.
|
||||
augroup nuwiki_coc_register
|
||||
autocmd!
|
||||
autocmd User CocNvimInit ++once call s:coc_register()
|
||||
augroup END
|
||||
endif
|
||||
endfunction
|
||||
|
||||
function! s:coc_register() abort
|
||||
let l:cfg = s:settings()
|
||||
" Call coc#config directly — `exists('*coc#config')` can't be trusted because
|
||||
" it doesn't trigger autoloading, so we let the call autoload coc.vim and
|
||||
" fall back to the manual snippet only if coc genuinely isn't there.
|
||||
try
|
||||
call coc#config('languageserver.nuwiki', {
|
||||
\ 'command': s:coc_bin,
|
||||
\ 'filetypes': ['vimwiki'],
|
||||
\ 'initializationOptions': l:cfg,
|
||||
\ 'settings': { 'nuwiki': l:cfg },
|
||||
\ })
|
||||
catch /^Vim\%((\a\+)\)\=:E11[78]/
|
||||
call s:print_coc_snippet(s:coc_bin)
|
||||
endtry
|
||||
endfunction
|
||||
|
||||
function! s:print_coc_snippet(bin) abort
|
||||
echohl WarningMsg
|
||||
echom 'nuwiki: coc.nvim detected. Add this to your coc-settings.json:'
|
||||
echom ' "languageserver": {'
|
||||
echom ' "nuwiki": {'
|
||||
echom ' "command": "' . a:bin . '",'
|
||||
echom ' "filetypes": ["vimwiki"],'
|
||||
echom ' "initializationOptions": ' . json_encode(s:settings())
|
||||
echom ' }'
|
||||
echom ' }'
|
||||
echohl None
|
||||
endfunction
|
||||
|
||||
function! s:bin_path() abort
|
||||
if exists('g:nuwiki_binary_path') && filereadable(g:nuwiki_binary_path)
|
||||
return g:nuwiki_binary_path
|
||||
@@ -89,12 +134,16 @@ function! s:settings() abort
|
||||
\ 'link_severity': get(g:, 'nuwiki_link_severity', 'warn'),
|
||||
\ },
|
||||
\ }
|
||||
" Include the per-wiki list when configured via g:nuwiki_wikis so the
|
||||
" server knows each wiki's root, diary path, etc. Without this it only
|
||||
" sees wiki_root and resolves links relative to that directory instead
|
||||
" of the individual wiki's root.
|
||||
if exists('g:nuwiki_wikis') && !empty(g:nuwiki_wikis)
|
||||
let l:cfg['wikis'] = g:nuwiki_wikis
|
||||
" Wiki list comes from the shared resolver (nuwiki#config#wikis), so the
|
||||
" server payload and the buffer-side commands agree on which wikis exist —
|
||||
" native g:nuwiki_wikis or an upstream g:vimwiki_list, globals already folded.
|
||||
let l:wikis = nuwiki#config#wikis()
|
||||
if !empty(l:wikis)
|
||||
let l:cfg['wikis'] = l:wikis
|
||||
else
|
||||
" Single-wiki shorthand: fold the global scalars into the top-level payload
|
||||
" so the server's single-wiki desugaring honours them.
|
||||
call extend(l:cfg, nuwiki#config#scalar_globals(), 'force')
|
||||
endif
|
||||
return l:cfg
|
||||
endfunction
|
||||
|
||||
@@ -53,6 +53,10 @@ pub struct PreformattedNode {
|
||||
pub span: Span,
|
||||
pub content: String,
|
||||
pub language: Option<String>,
|
||||
/// Verbatim text after the opening `{{{`, trailing whitespace trimmed
|
||||
/// (e.g. `python` or `class="brush: python"`). vimwiki inserts this as
|
||||
/// raw attributes on the `<pre>` tag, so we preserve it for byte-parity.
|
||||
pub attrs: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
|
||||
@@ -17,6 +17,22 @@ pub enum Keyword {
|
||||
Stopped,
|
||||
}
|
||||
|
||||
impl Keyword {
|
||||
/// The literal source text of the keyword (e.g. `"TODO"`). Used both for
|
||||
/// the rendered span and for anchor/id derivation via [`inline_text`].
|
||||
pub fn label(self) -> &'static str {
|
||||
match self {
|
||||
Keyword::Todo => "TODO",
|
||||
Keyword::Done => "DONE",
|
||||
Keyword::Started => "STARTED",
|
||||
Keyword::Fixme => "FIXME",
|
||||
Keyword::Fixed => "FIXED",
|
||||
Keyword::Xxx => "XXX",
|
||||
Keyword::Stopped => "STOPPED",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum InlineNode {
|
||||
Text(TextNode),
|
||||
@@ -89,6 +105,7 @@ fn push_inline_text(inlines: &[InlineNode], out: &mut String) {
|
||||
InlineNode::Superscript(s) => push_inline_text(&s.children, out),
|
||||
InlineNode::Subscript(s) => push_inline_text(&s.children, out),
|
||||
InlineNode::Color(c) => push_inline_text(&c.children, out),
|
||||
InlineNode::Keyword(k) => out.push_str(k.keyword.label()),
|
||||
InlineNode::WikiLink(w) => match &w.description {
|
||||
Some(d) => push_inline_text(d, out),
|
||||
None => {
|
||||
|
||||
@@ -59,6 +59,11 @@ pub struct HtmlRenderer {
|
||||
/// vimwiki `html_header_numbering_sym`: a symbol appended after the
|
||||
/// section number (e.g. `.` → `1.2. Heading`). Empty by default.
|
||||
header_numbering_sym: String,
|
||||
/// vimwiki `toc_header`: the heading text that identifies the TOC
|
||||
/// section. A heading matching this value (case-insensitive) is wrapped
|
||||
/// in `<div class="toc">…</div>` on export, matching upstream so vimwiki's
|
||||
/// stylesheet applies.
|
||||
toc_header: String,
|
||||
/// vimwiki `valid_html_tags`: inline HTML tag names passed through to
|
||||
/// export verbatim (instead of escaping `<`/`>`). Empty = escape all.
|
||||
/// `span` is always allowed so colour spans render.
|
||||
@@ -90,6 +95,7 @@ impl HtmlRenderer {
|
||||
color_template: "<span style=\"__STYLE__\">__CONTENT__</span>".to_string(),
|
||||
header_numbering: 0,
|
||||
header_numbering_sym: String::new(),
|
||||
toc_header: String::new(),
|
||||
valid_html_tags: Vec::new(),
|
||||
emoji_enable: false,
|
||||
text_ignore_newline: true,
|
||||
@@ -183,6 +189,13 @@ impl HtmlRenderer {
|
||||
self.header_numbering_sym = sym.into();
|
||||
self
|
||||
}
|
||||
|
||||
/// Set the vimwiki TOC heading text. When a heading matches this value
|
||||
/// (case-insensitive) it gets `class="toc"` in HTML export.
|
||||
pub fn with_toc_header(mut self, header: impl Into<String>) -> Self {
|
||||
self.toc_header = header.into();
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
/// Running state for vimwiki-style HTML section numbering
|
||||
@@ -265,10 +278,26 @@ impl HtmlRenderer {
|
||||
// quotes) go through `render_block` and are left unnumbered, matching
|
||||
// upstream which only numbers document-level headers.
|
||||
let mut numberer = HeadingNumberer::new(self.header_numbering, &self.header_numbering_sym);
|
||||
// Stack of (level, anchor) for the headings enclosing the current one,
|
||||
// so each heading can carry vimwiki's hierarchical id (the parent
|
||||
// anchors joined by `-`, e.g. `Title-Section`).
|
||||
let mut ancestors: Vec<(u8, String)> = Vec::new();
|
||||
for block in &doc.children {
|
||||
if let BlockNode::Heading(n) = block {
|
||||
let number = numberer.prefix(n.level.clamp(1, 6));
|
||||
self.render_heading(n, &number, w)?;
|
||||
let level = n.level.clamp(1, 6);
|
||||
let number = numberer.prefix(level);
|
||||
let anchor = crate::ast::inline_text(&n.children);
|
||||
while ancestors.last().is_some_and(|(l, _)| *l >= level) {
|
||||
ancestors.pop();
|
||||
}
|
||||
let mut hier = String::new();
|
||||
for (_, a) in &ancestors {
|
||||
hier.push_str(a);
|
||||
hier.push('-');
|
||||
}
|
||||
hier.push_str(&anchor);
|
||||
ancestors.push((level, anchor));
|
||||
self.render_heading(n, &number, &hier, w)?;
|
||||
} else {
|
||||
self.render_block(block, w)?;
|
||||
}
|
||||
@@ -278,7 +307,7 @@ impl HtmlRenderer {
|
||||
|
||||
fn render_block(&self, block: &BlockNode, w: &mut dyn Write) -> io::Result<()> {
|
||||
match block {
|
||||
BlockNode::Heading(n) => self.render_heading(n, "", w),
|
||||
BlockNode::Heading(n) => self.render_heading(n, "", "", w),
|
||||
BlockNode::Paragraph(n) => self.render_paragraph(n, w),
|
||||
BlockNode::HorizontalRule(n) => self.render_hr(n, w),
|
||||
BlockNode::Blockquote(n) => self.render_blockquote(n, w),
|
||||
@@ -294,13 +323,14 @@ impl HtmlRenderer {
|
||||
}
|
||||
|
||||
fn render_tag(&self, n: &TagNode, w: &mut dyn Write) -> io::Result<()> {
|
||||
// `id="tag-…"` lets `[[Page#tag-name]]` jump to the rendered tag.
|
||||
// `id="…"` (bare tag name, matching vimwiki) lets `[[Page#name]]` jump
|
||||
// to the rendered tag.
|
||||
w.write_all(b"<div class=\"tags\">")?;
|
||||
for (i, name) in n.tags.iter().enumerate() {
|
||||
if i > 0 {
|
||||
w.write_all(b" ")?;
|
||||
}
|
||||
w.write_all(b"<span class=\"tag\" id=\"tag-")?;
|
||||
w.write_all(b"<span class=\"tag\" id=\"")?;
|
||||
write_escaped(name, w)?;
|
||||
w.write_all(b"\">")?;
|
||||
write_escaped(name, w)?;
|
||||
@@ -311,26 +341,71 @@ impl HtmlRenderer {
|
||||
|
||||
/// Render a heading. `number` is the optional section-number prefix
|
||||
/// (already including its trailing symbol and space, e.g. `"1.2. "`);
|
||||
/// pass `""` for no numbering.
|
||||
fn render_heading(&self, n: &HeadingNode, number: &str, w: &mut dyn Write) -> io::Result<()> {
|
||||
/// pass `""` for no numbering. `hier_id` is the hierarchical anchor id
|
||||
/// (parent anchors joined by `-`); pass `""` to default it to the flat id.
|
||||
fn render_heading(
|
||||
&self,
|
||||
n: &HeadingNode,
|
||||
number: &str,
|
||||
hier_id: &str,
|
||||
w: &mut dyn Write,
|
||||
) -> io::Result<()> {
|
||||
let level = n.level.clamp(1, 6);
|
||||
let class = if n.centered {
|
||||
" class=\"centered\""
|
||||
} else {
|
||||
""
|
||||
};
|
||||
// `id` is the heading's plain text (vimwiki's anchor scheme), so
|
||||
// `[[Page#Heading]]` and TOC `#Heading` links land here. Matches the
|
||||
// anchor text the LSP validates and the resolver emits.
|
||||
let anchor = crate::ast::inline_text(&n.children);
|
||||
write!(w, "<h{level}{class} id=\"")?;
|
||||
// Inside a heading, render TODO/DONE/… as plain text rather than a
|
||||
// keyword badge — a section title that *is* a keyword (`== TODO ==`)
|
||||
// should look like a header, not an inline tag. (The keyword text is
|
||||
// still part of `anchor` above, so the id stays correct.)
|
||||
let body = flatten_keywords(&n.children);
|
||||
|
||||
// The TOC section heading (vimwiki `toc_header`) is wrapped in a
|
||||
// `<div class="toc">`, matching upstream so vimwiki's `.toc { … }`
|
||||
// stylesheet rules apply. Kept in its simple form.
|
||||
let is_toc = !self.toc_header.is_empty() && anchor.eq_ignore_ascii_case(&self.toc_header);
|
||||
if is_toc {
|
||||
w.write_all(b"<div class=\"toc\">")?;
|
||||
write!(w, "<h{level} id=\"")?;
|
||||
write_escaped(&anchor, w)?;
|
||||
write!(w, "\">")?;
|
||||
if !number.is_empty() {
|
||||
write_escaped(number, w)?;
|
||||
}
|
||||
self.render_inlines(&body, w)?;
|
||||
write!(w, "</h{level}></div>")?;
|
||||
return writeln!(w);
|
||||
}
|
||||
|
||||
// Centered headings keep their `class="centered"` form.
|
||||
if n.centered {
|
||||
write!(w, "<h{level} class=\"centered\" id=\"")?;
|
||||
write_escaped(&anchor, w)?;
|
||||
write!(w, "\">")?;
|
||||
if !number.is_empty() {
|
||||
write_escaped(number, w)?;
|
||||
}
|
||||
self.render_inlines(&body, w)?;
|
||||
write!(w, "</h{level}>")?;
|
||||
return writeln!(w);
|
||||
}
|
||||
|
||||
// Regular heading: vimwiki's structure — a wrapping `<div>` carrying the
|
||||
// hierarchical id, `class="header"` on the heading, and an in-heading
|
||||
// self-anchor linking to that id. The heading keeps the flat id so
|
||||
// existing intra-page TOC / `#anchor` links still resolve.
|
||||
let hier = if hier_id.is_empty() { &anchor } else { hier_id };
|
||||
w.write_all(b"<div id=\"")?;
|
||||
write_escaped(hier, w)?;
|
||||
write!(w, "\"><h{level} id=\"")?;
|
||||
write_escaped(&anchor, w)?;
|
||||
w.write_all(b"\" class=\"header\"><a href=\"#")?;
|
||||
write_escaped(hier, w)?;
|
||||
write!(w, "\">")?;
|
||||
if !number.is_empty() {
|
||||
write_escaped(number, w)?;
|
||||
}
|
||||
self.render_inlines(&n.children, w)?;
|
||||
writeln!(w, "</h{level}>")
|
||||
self.render_inlines(&body, w)?;
|
||||
write!(w, "</a></h{level}></div>")?;
|
||||
writeln!(w)
|
||||
}
|
||||
|
||||
fn render_paragraph(&self, n: &ParagraphNode, w: &mut dyn Write) -> io::Result<()> {
|
||||
@@ -357,16 +432,16 @@ impl HtmlRenderer {
|
||||
}
|
||||
|
||||
fn render_preformatted(&self, n: &PreformattedNode, w: &mut dyn Write) -> io::Result<()> {
|
||||
match &n.language {
|
||||
Some(lang) => {
|
||||
w.write_all(b"<pre><code class=\"language-")?;
|
||||
write_escaped(lang, w)?;
|
||||
w.write_all(b"\">")?;
|
||||
}
|
||||
None => w.write_all(b"<pre><code>")?,
|
||||
// vimwiki drops the verbatim fence text into the `<pre>` tag as raw
|
||||
// attributes (`{{{python` → `<pre python>`), with no inner `<code>`.
|
||||
// We mirror that so highlighters wired for vimwiki output still apply.
|
||||
if n.attrs.is_empty() {
|
||||
w.write_all(b"<pre>")?;
|
||||
} else {
|
||||
write!(w, "<pre {}>", n.attrs)?;
|
||||
}
|
||||
write_escaped(&n.content, w)?;
|
||||
w.write_all(b"</code></pre>\n")
|
||||
w.write_all(b"</pre>\n")
|
||||
}
|
||||
|
||||
fn render_math_block(&self, n: &MathBlockNode, w: &mut dyn Write) -> io::Result<()> {
|
||||
@@ -683,16 +758,16 @@ impl HtmlRenderer {
|
||||
// One class per keyword so stylesheets can colour them independently
|
||||
// (e.g. red TODO/FIXME/XXX vs green DONE/FIXED/STARTED). TODO keeps the
|
||||
// vimwiki `todo` class so stock vimwiki CSS still styles it.
|
||||
let (label, class) = match n.keyword {
|
||||
Keyword::Todo => ("TODO", "todo"),
|
||||
Keyword::Done => ("DONE", "done"),
|
||||
Keyword::Started => ("STARTED", "started"),
|
||||
Keyword::Fixme => ("FIXME", "fixme"),
|
||||
Keyword::Fixed => ("FIXED", "fixed"),
|
||||
Keyword::Xxx => ("XXX", "xxx"),
|
||||
Keyword::Stopped => ("STOPPED", "stopped"),
|
||||
let class = match n.keyword {
|
||||
Keyword::Todo => "todo",
|
||||
Keyword::Done => "done",
|
||||
Keyword::Started => "started",
|
||||
Keyword::Fixme => "fixme",
|
||||
Keyword::Fixed => "fixed",
|
||||
Keyword::Xxx => "xxx",
|
||||
Keyword::Stopped => "stopped",
|
||||
};
|
||||
write!(w, "<span class=\"{class}\">{label}</span>")
|
||||
write!(w, "<span class=\"{class}\">{}</span>", n.keyword.label())
|
||||
}
|
||||
|
||||
fn render_color(&self, n: &ColorNode, w: &mut dyn Write) -> io::Result<()> {
|
||||
@@ -869,6 +944,51 @@ fn table_spans(table: &TableNode) -> Vec<Vec<CellLayout>> {
|
||||
/// wiki pages become `path.html`; interwiki links land in sibling
|
||||
/// directories; diary entries land under `diary/`; file/local schemes
|
||||
/// use their literal path; anchor-only links collapse to a fragment.
|
||||
/// Return a copy of `nodes` with every `Keyword` replaced by its literal text.
|
||||
/// Used for heading content so a keyword in a title renders as plain text (with
|
||||
/// the heading's own styling) rather than as an inline keyword badge.
|
||||
fn flatten_keywords(nodes: &[InlineNode]) -> Vec<InlineNode> {
|
||||
nodes
|
||||
.iter()
|
||||
.map(|n| match n {
|
||||
InlineNode::Keyword(k) => InlineNode::Text(TextNode {
|
||||
span: k.span,
|
||||
content: k.keyword.label().to_string(),
|
||||
}),
|
||||
InlineNode::Bold(b) => InlineNode::Bold(BoldNode {
|
||||
span: b.span,
|
||||
children: flatten_keywords(&b.children),
|
||||
}),
|
||||
InlineNode::Italic(i) => InlineNode::Italic(ItalicNode {
|
||||
span: i.span,
|
||||
children: flatten_keywords(&i.children),
|
||||
}),
|
||||
InlineNode::BoldItalic(b) => InlineNode::BoldItalic(BoldItalicNode {
|
||||
span: b.span,
|
||||
children: flatten_keywords(&b.children),
|
||||
}),
|
||||
InlineNode::Strikethrough(s) => InlineNode::Strikethrough(StrikethroughNode {
|
||||
span: s.span,
|
||||
children: flatten_keywords(&s.children),
|
||||
}),
|
||||
InlineNode::Superscript(s) => InlineNode::Superscript(SuperscriptNode {
|
||||
span: s.span,
|
||||
children: flatten_keywords(&s.children),
|
||||
}),
|
||||
InlineNode::Subscript(s) => InlineNode::Subscript(SubscriptNode {
|
||||
span: s.span,
|
||||
children: flatten_keywords(&s.children),
|
||||
}),
|
||||
InlineNode::Color(c) => InlineNode::Color(ColorNode {
|
||||
span: c.span,
|
||||
color: c.color.clone(),
|
||||
children: flatten_keywords(&c.children),
|
||||
}),
|
||||
other => other.clone(),
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub fn default_link_resolver(target: &LinkTarget) -> String {
|
||||
if matches!(target.kind, LinkKind::AnchorOnly) {
|
||||
return match &target.anchor {
|
||||
|
||||
@@ -81,6 +81,9 @@ pub enum VimwikiTokenKind {
|
||||
PreformattedOpen {
|
||||
language: Option<String>,
|
||||
attrs: HashMap<String, String>,
|
||||
/// Verbatim text after `{{{` (trailing whitespace trimmed), preserved
|
||||
/// so the HTML renderer can reproduce vimwiki's raw `<pre …>` attrs.
|
||||
raw: String,
|
||||
},
|
||||
PreformattedClose,
|
||||
PreformattedLine(String),
|
||||
@@ -421,7 +424,14 @@ impl<'src> LexState<'src> {
|
||||
// pairs separated by spaces. Keep parsing forgiving.
|
||||
let after = trimmed[3..].trim();
|
||||
let (language, attrs) = parse_fence_attrs(after);
|
||||
let kind = VimwikiTokenKind::PreformattedOpen { language, attrs };
|
||||
// vimwiki keeps everything after `{{{` (minus trailing whitespace) and
|
||||
// drops it verbatim into the `<pre>` tag, so capture it unparsed.
|
||||
let raw = trimmed[3..].trim_end().to_string();
|
||||
let kind = VimwikiTokenKind::PreformattedOpen {
|
||||
language,
|
||||
attrs,
|
||||
raw,
|
||||
};
|
||||
let span = Span::new(self.pos_in_line(indent), self.line_end_pos(line));
|
||||
self.push(kind, span);
|
||||
self.mode = BlockMode::Preformatted;
|
||||
@@ -516,10 +526,9 @@ impl<'src> LexState<'src> {
|
||||
if level == 0 {
|
||||
return false;
|
||||
}
|
||||
// Must be followed by at least one space.
|
||||
if leading_ws + level >= bytes.len() || bytes[leading_ws + level] != b' ' {
|
||||
return false;
|
||||
}
|
||||
// vimwiki accepts both spaced (`== H ==`) and spaceless (`==H==`)
|
||||
// headings, so we don't require a space after the opening `=`s. The
|
||||
// title is still trimmed of one optional space per side below.
|
||||
|
||||
// Trailing `=`s of the same level.
|
||||
let trimmed_end = line.trim_end_matches([' ', '\t']);
|
||||
@@ -865,8 +874,10 @@ impl<'src> LexState<'src> {
|
||||
flush(self, &mut buf, &mut buf_start, abs_col);
|
||||
self.emit(VimwikiTokenKind::WikiLinkOpen, abs_col, abs_col + 2);
|
||||
i += 2;
|
||||
// Lex until `]]` (absolute index into `slice`).
|
||||
let close_abs_in_slice = after_open.find("]]").map(|c| i + c);
|
||||
// Lex until the closing `]]`. A single `]` inside the body —
|
||||
// e.g. a `[TICKET-1]` in the description — is literal text, so
|
||||
// we balance inner `[ ]` rather than stopping at the first `]]`.
|
||||
let close_abs_in_slice = find_wikilink_close(after_open).map(|c| i + c);
|
||||
let inner_end = close_abs_in_slice.unwrap_or(slice.len());
|
||||
self.lex_link_body(slice, i, line_col_start, inner_end, true);
|
||||
i = inner_end;
|
||||
@@ -1052,6 +1063,27 @@ impl<'src> LexState<'src> {
|
||||
/// Parse the bit after `{{{` on a fence line. Format is forgiving:
|
||||
/// `lang` (single word) followed by optional `key=value` pairs separated
|
||||
/// by spaces.
|
||||
/// Byte offset of the closing `]]` for a wikilink body that begins at the start
|
||||
/// of `s` (just after the opening `[[`). Inner `[ ]` pairs are balanced so a
|
||||
/// bracketed description like `[[page|Task [B-1]]]` keeps the `[B-1]` and closes
|
||||
/// at the final `]]`. A stray single `]` (no matching `[`) is treated as literal
|
||||
/// body text. Returns `None` if no closing `]]` is found.
|
||||
fn find_wikilink_close(s: &str) -> Option<usize> {
|
||||
let b = s.as_bytes();
|
||||
let mut depth: u32 = 0;
|
||||
let mut i = 0;
|
||||
while i < b.len() {
|
||||
match b[i] {
|
||||
b'[' => depth += 1,
|
||||
b']' if depth > 0 => depth -= 1, // closes an inner '['
|
||||
b']' if i + 1 < b.len() && b[i + 1] == b']' => return Some(i),
|
||||
_ => {}
|
||||
}
|
||||
i += 1;
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
fn parse_fence_attrs(s: &str) -> (Option<String>, HashMap<String, String>) {
|
||||
let mut attrs = HashMap::new();
|
||||
let mut language: Option<String> = None;
|
||||
|
||||
@@ -374,8 +374,8 @@ impl<'a> ParseState<'a> {
|
||||
let open = self.advance().unwrap();
|
||||
let span_start = open.span.start;
|
||||
let mut span_end = open.span.end;
|
||||
let language = match &open.kind {
|
||||
K::PreformattedOpen { language, .. } => language.clone(),
|
||||
let (language, attrs) = match &open.kind {
|
||||
K::PreformattedOpen { language, raw, .. } => (language.clone(), raw.clone()),
|
||||
_ => unreachable!(),
|
||||
};
|
||||
let mut content = String::new();
|
||||
@@ -390,6 +390,7 @@ impl<'a> ParseState<'a> {
|
||||
span: Span::new(span_start, span_end),
|
||||
content,
|
||||
language,
|
||||
attrs,
|
||||
});
|
||||
}
|
||||
K::PreformattedLine(s) => {
|
||||
@@ -413,6 +414,7 @@ impl<'a> ParseState<'a> {
|
||||
span: Span::new(span_start, span_end),
|
||||
content,
|
||||
language,
|
||||
attrs,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -63,7 +63,11 @@ fn heading_levels() {
|
||||
for level in 1..=6 {
|
||||
let bars = "=".repeat(level);
|
||||
let out = render(&format!("{bars} Title {bars}\n"));
|
||||
assert!(out.starts_with(&format!("<h{level} id=\"Title\">Title</h{level}>")));
|
||||
// vimwiki structure: <div id="hier"><h{l} id="flat" class="header">
|
||||
// <a href="#hier">…</a></h{l}></div>. A lone heading has hier == flat.
|
||||
assert!(out.starts_with(&format!(
|
||||
"<div id=\"Title\"><h{level} id=\"Title\" class=\"header\"><a href=\"#Title\">Title</a></h{level}></div>"
|
||||
)), "got: {out}");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -73,6 +77,64 @@ fn centered_heading_gets_centered_class() {
|
||||
assert!(out.contains("<h1 class=\"centered\" id=\"T\">T</h1>"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn spaceless_heading_renders_as_heading() {
|
||||
// `==Heading==` (no surrounding spaces) is a heading, like vimwiki — not a
|
||||
// literal `<p>==Heading==</p>`.
|
||||
let out = render("==Plain==\n");
|
||||
assert!(
|
||||
out.contains(
|
||||
"<div id=\"Plain\"><h2 id=\"Plain\" class=\"header\">\
|
||||
<a href=\"#Plain\">Plain</a></h2></div>"
|
||||
),
|
||||
"got: {out}"
|
||||
);
|
||||
// The spaceless + keyword combo from the report: a header, not a badge.
|
||||
let out2 = render("==TODO==\n");
|
||||
assert!(
|
||||
out2.contains("<h2 id=\"TODO\" class=\"header\">"),
|
||||
"got: {out2}"
|
||||
);
|
||||
assert!(!out2.contains("<p>"), "not a paragraph: {out2}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn heading_keyword_renders_as_plain_text_not_badge() {
|
||||
// `== TODO ==` is a section title, not an inline keyword: it must render as
|
||||
// a normal header (with a correct, non-empty id), not a `<span class="todo">`
|
||||
// badge. Regression for the exported header losing its header styling + id.
|
||||
let out = render("== TODO ==\n");
|
||||
assert!(
|
||||
out.contains(
|
||||
"<div id=\"TODO\"><h2 id=\"TODO\" class=\"header\">\
|
||||
<a href=\"#TODO\">TODO</a></h2></div>"
|
||||
),
|
||||
"got: {out}"
|
||||
);
|
||||
assert!(!out.contains("class=\"todo\""), "no keyword badge: {out}");
|
||||
|
||||
// A keyword mid-title keeps its text in the id (no dropped word / double
|
||||
// space) and still renders plainly.
|
||||
let out2 = render("= My TODO list =\n");
|
||||
assert!(out2.contains("id=\"My TODO list\""), "got: {out2}");
|
||||
assert!(!out2.contains("class=\"todo\""), "got: {out2}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wikilink_description_keeps_inner_brackets() {
|
||||
// A `]` inside a wikilink description (e.g. a `[TICKET-1]`) must not close
|
||||
// the link early. Regression for descriptions with brackets being mangled.
|
||||
let out = render("[[page|Task [B-1]]]\n");
|
||||
assert!(
|
||||
out.contains("<a href=\"page.html\">Task [B-1]</a>"),
|
||||
"got: {out}"
|
||||
);
|
||||
// Two bracketed links on one line both resolve.
|
||||
let out2 = render("[[a|X [1]]] and [[b|Y [2]]]\n");
|
||||
assert!(out2.contains("<a href=\"a.html\">X [1]</a>"), "got: {out2}");
|
||||
assert!(out2.contains("<a href=\"b.html\">Y [2]</a>"), "got: {out2}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn paragraph_wraps_inline_content() {
|
||||
let out = render("Hello world\n");
|
||||
@@ -94,11 +156,26 @@ fn blockquote_lines_become_paragraphs() {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn preformatted_block_emits_pre_code_with_language_class() {
|
||||
fn preformatted_block_emits_pre_with_raw_fence_attrs() {
|
||||
// vimwiki drops the verbatim fence text into the <pre> tag (`{{{rust` →
|
||||
// `<pre rust>`), with no inner <code> wrapper.
|
||||
let out = render("{{{rust\nfn main() {}\n}}}\n");
|
||||
assert!(out.contains("<pre><code class=\"language-rust\">"));
|
||||
assert!(out.contains("<pre rust>"), "got: {out}");
|
||||
assert!(out.contains("fn main() {}"));
|
||||
assert!(out.contains("</code></pre>"));
|
||||
assert!(out.contains("</pre>"));
|
||||
assert!(!out.contains("<code"), "no inner code element: {out}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn preformatted_block_without_attrs_is_bare_pre() {
|
||||
let out = render("{{{\nplain\n}}}\n");
|
||||
assert!(out.contains("<pre>plain"), "got: {out}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn preformatted_block_keeps_full_attr_string() {
|
||||
let out = render("{{{class=\"brush: python\"\nx\n}}}\n");
|
||||
assert!(out.contains("<pre class=\"brush: python\">"), "got: {out}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -244,7 +321,10 @@ fn heading_id_matches_anchor_link_href() {
|
||||
// for "exported HTML anchor links don't work".
|
||||
let out = render("= My Section =\n\n[[#My Section]]\n");
|
||||
assert!(
|
||||
out.contains("<h1 id=\"My Section\">My Section</h1>"),
|
||||
out.contains(
|
||||
"<div id=\"My Section\"><h1 id=\"My Section\" class=\"header\">\
|
||||
<a href=\"#My Section\">My Section</a></h1></div>"
|
||||
),
|
||||
"heading id: {out}"
|
||||
);
|
||||
assert!(
|
||||
@@ -253,6 +333,39 @@ fn heading_id_matches_anchor_link_href() {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn toc_header_heading_wrapped_in_div_toc() {
|
||||
// The TOC section heading gets a `<div class="toc">` wrapper (vimwiki
|
||||
// scheme) so `.toc` stylesheet rules apply. The class is on the div, not
|
||||
// the heading.
|
||||
let doc = VimwikiSyntax::new().parse("== Contents ==\n");
|
||||
let out = HtmlRenderer::new()
|
||||
.with_toc_header("Contents")
|
||||
.render_to_string(&doc)
|
||||
.unwrap();
|
||||
assert!(
|
||||
out.contains("<div class=\"toc\"><h2 id=\"Contents\">Contents</h2></div>"),
|
||||
"got: {out}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn non_toc_heading_not_wrapped() {
|
||||
let doc = VimwikiSyntax::new().parse("== Intro ==\n");
|
||||
let out = HtmlRenderer::new()
|
||||
.with_toc_header("Contents")
|
||||
.render_to_string(&doc)
|
||||
.unwrap();
|
||||
assert!(
|
||||
out.contains(
|
||||
"<div id=\"Intro\"><h2 id=\"Intro\" class=\"header\">\
|
||||
<a href=\"#Intro\">Intro</a></h2></div>"
|
||||
),
|
||||
"got: {out}"
|
||||
);
|
||||
assert!(!out.contains("class=\"toc\""), "got: {out}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn interwiki_numbered_link() {
|
||||
let out = render("[[wiki1:Page]]\n");
|
||||
@@ -315,7 +428,7 @@ fn template_substitutes_content_and_title() {
|
||||
);
|
||||
let out = renderer.render_to_string(&doc).unwrap();
|
||||
assert!(out.contains("<title>My Page</title>"));
|
||||
assert!(out.contains("<body><h1 id=\"Hello\">Hello</h1>"));
|
||||
assert!(out.contains("<body><div id=\"Hello\"><h1 id=\"Hello\" class=\"header\"><a href=\"#Hello\">Hello</a></h1></div>"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -324,7 +437,7 @@ fn template_with_missing_title_substitutes_empty_string() {
|
||||
let renderer = HtmlRenderer::new().with_template("<title>{{title}}</title>{{content}}");
|
||||
let out = renderer.render_to_string(&doc).unwrap();
|
||||
assert!(out.contains("<title></title>"));
|
||||
assert!(out.contains("<h1 id=\"Heading\">Heading</h1>"));
|
||||
assert!(out.contains("<div id=\"Heading\"><h1 id=\"Heading\" class=\"header\"><a href=\"#Heading\">Heading</a></h1></div>"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -340,7 +453,7 @@ fn template_substitutes_vimwiki_percent_placeholders() {
|
||||
let out = renderer.render_to_string(&doc).unwrap();
|
||||
assert!(out.contains("<title>My Page</title>"), "title: {out}");
|
||||
assert!(
|
||||
out.contains("<body><h1 id=\"Hello\">Hello</h1>"),
|
||||
out.contains("<body><div id=\"Hello\"><h1 id=\"Hello\" class=\"header\"><a href=\"#Hello\">Hello</a></h1></div>"),
|
||||
"content: {out}"
|
||||
);
|
||||
assert!(out.contains("href=\"../style.css\""), "root_path: {out}");
|
||||
@@ -434,7 +547,7 @@ fn x() {}
|
||||
let out = HtmlRenderer::new().render_to_string(&doc).unwrap();
|
||||
// A few sanity checks; the full output is exercised piece-by-piece above.
|
||||
for needle in [
|
||||
"<h1 id=\"Heading\">Heading</h1>",
|
||||
"<div id=\"Heading\"><h1 id=\"Heading\" class=\"header\"><a href=\"#Heading\">Heading</a></h1></div>",
|
||||
"<strong>bold</strong>",
|
||||
"<em>italic</em>",
|
||||
"<code>code</code>",
|
||||
@@ -442,7 +555,7 @@ fn x() {}
|
||||
"<blockquote>",
|
||||
"<hr>",
|
||||
"<table>",
|
||||
"<pre><code class=\"language-rust\">",
|
||||
"<pre rust>",
|
||||
] {
|
||||
assert!(
|
||||
out.contains(needle),
|
||||
|
||||
@@ -85,6 +85,32 @@ fn centered_heading_is_marked_centered() {
|
||||
assert!(matches!(lexed[2], HeadingClose));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn spaceless_heading_is_a_heading() {
|
||||
// vimwiki accepts `==Heading==` with no spaces around the text.
|
||||
let lexed = lex("==Plain==\n");
|
||||
assert_eq!(
|
||||
lexed,
|
||||
vec![
|
||||
HeadingOpen {
|
||||
level: 2,
|
||||
centered: false
|
||||
},
|
||||
text("Plain"),
|
||||
HeadingClose,
|
||||
Newline,
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn marker_only_or_unbalanced_lines_are_not_headings() {
|
||||
// No title between markers, and trailing `=` not at line end → plain text.
|
||||
assert!(!matches!(lex("======\n")[0], HeadingOpen { .. }));
|
||||
assert!(!matches!(lex("==> arrow\n")[0], HeadingOpen { .. }));
|
||||
assert!(!matches!(lex("==a==b\n")[0], HeadingOpen { .. }));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn heading_with_inline_bold() {
|
||||
let lexed = lex("== Hello *world* ==\n");
|
||||
@@ -179,7 +205,13 @@ fn preformatted_block_parses_language() {
|
||||
#[test]
|
||||
fn preformatted_block_parses_attrs() {
|
||||
let lexed = lex("{{{rust class=\"hl\" key=val\nx\n}}}\n");
|
||||
let PreformattedOpen { language, attrs } = &lexed[0] else {
|
||||
let PreformattedOpen {
|
||||
language,
|
||||
attrs,
|
||||
raw,
|
||||
..
|
||||
} = &lexed[0]
|
||||
else {
|
||||
panic!("expected PreformattedOpen");
|
||||
};
|
||||
assert_eq!(language.as_deref(), Some("rust"));
|
||||
@@ -187,6 +219,8 @@ fn preformatted_block_parses_attrs() {
|
||||
expected.insert("class".into(), "hl".into());
|
||||
expected.insert("key".into(), "val".into());
|
||||
assert_eq!(attrs, &expected);
|
||||
// The verbatim fence text is preserved for the HTML renderer.
|
||||
assert_eq!(raw, "rust class=\"hl\" key=val");
|
||||
}
|
||||
|
||||
// ===== Math block =====
|
||||
|
||||
@@ -190,8 +190,9 @@ fn file_tags_are_deduped_in_metadata() {
|
||||
fn renderer_emits_div_with_tag_spans() {
|
||||
let html = render(":todo:done:\n");
|
||||
assert!(html.contains("<div class=\"tags\">"));
|
||||
assert!(html.contains("<span class=\"tag\" id=\"tag-todo\">todo</span>"));
|
||||
assert!(html.contains("<span class=\"tag\" id=\"tag-done\">done</span>"));
|
||||
// Bare tag name as the id (vimwiki parity), so `[[Page#todo]]` resolves.
|
||||
assert!(html.contains("<span class=\"tag\" id=\"todo\">todo</span>"));
|
||||
assert!(html.contains("<span class=\"tag\" id=\"done\">done</span>"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -17,5 +17,5 @@ name = "nuwiki-ls"
|
||||
path = "src/main.rs"
|
||||
|
||||
[dependencies]
|
||||
nuwiki-lsp = { path = "../nuwiki-lsp", version = "0.1.0" }
|
||||
nuwiki-lsp = { path = "../nuwiki-lsp", version = "0.4.2" }
|
||||
tokio = { version = "1", features = ["macros", "rt-multi-thread", "io-std"] }
|
||||
|
||||
@@ -13,7 +13,7 @@ homepage.workspace = true
|
||||
workspace = true
|
||||
|
||||
[dependencies]
|
||||
nuwiki-core = { path = "../nuwiki-core", version = "0.1.0" }
|
||||
nuwiki-core = { path = "../nuwiki-core", version = "0.4.2" }
|
||||
tower-lsp = "0.20"
|
||||
tokio = { version = "1", features = ["macros", "rt-multi-thread", "io-std", "sync", "fs"] }
|
||||
dashmap = "6"
|
||||
|
||||
@@ -41,6 +41,7 @@ pub const COMMANDS: &[&str] = &[
|
||||
"nuwiki.heading.removeLevel",
|
||||
"nuwiki.toc.generate",
|
||||
"nuwiki.links.generate",
|
||||
"nuwiki.links.generateForPath",
|
||||
"nuwiki.workspace.checkLinks",
|
||||
"nuwiki.workspace.findOrphans",
|
||||
"nuwiki.diary.openToday",
|
||||
@@ -117,6 +118,9 @@ pub(crate) async fn execute(
|
||||
"nuwiki.links.generate" => {
|
||||
links_generate(backend, args).map(|o| o.map(CommandOutcome::Edit))
|
||||
}
|
||||
"nuwiki.links.generateForPath" => {
|
||||
links_generate_for_path(backend, args).map(|o| o.map(CommandOutcome::Edit))
|
||||
}
|
||||
"nuwiki.workspace.checkLinks" => {
|
||||
workspace_check_links(backend, args).map(|o| o.map(CommandOutcome::Value))
|
||||
}
|
||||
@@ -304,6 +308,24 @@ fn parse_uri_arg(args: Vec<Value>) -> Result<UriArg, String> {
|
||||
serde_json::from_value(raw).map_err(|e| format!("invalid args: {e}"))
|
||||
}
|
||||
|
||||
/// Parse `{ uri, line? }` for the section generators (TOC / links / tags). The
|
||||
/// optional 0-based `line` is the cursor line the client sends, where a fresh
|
||||
/// section is inserted.
|
||||
fn parse_uri_line_arg(args: Vec<Value>) -> Result<(Url, Option<u32>), String> {
|
||||
#[derive(Deserialize)]
|
||||
struct A {
|
||||
uri: Url,
|
||||
#[serde(default)]
|
||||
line: Option<u32>,
|
||||
}
|
||||
let raw = args
|
||||
.into_iter()
|
||||
.next()
|
||||
.ok_or_else(|| "missing { uri } argument".to_string())?;
|
||||
let a: A = serde_json::from_value(raw).map_err(|e| format!("invalid args: {e}"))?;
|
||||
Ok((a.uri, a.line))
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Default)]
|
||||
struct OptionalUriArg {
|
||||
#[serde(default)]
|
||||
@@ -323,13 +345,13 @@ fn parse_optional_uri_arg(args: Vec<Value>) -> Result<OptionalUriArg, String> {
|
||||
}
|
||||
|
||||
fn toc_generate(backend: &Backend, args: Vec<Value>) -> Result<Option<WorkspaceEdit>, String> {
|
||||
let p = parse_uri_arg(args)?;
|
||||
let doc = match backend.documents.get(&p.uri) {
|
||||
let (uri, cursor_line) = parse_uri_line_arg(args)?;
|
||||
let doc = match backend.documents.get(&uri) {
|
||||
Some(d) => d,
|
||||
None => return Ok(None),
|
||||
};
|
||||
let utf8 = backend.use_utf8.load(Ordering::Relaxed);
|
||||
let wiki = backend.wiki_for_uri(&p.uri);
|
||||
let wiki = backend.wiki_for_uri(&uri);
|
||||
let (header, level) = wiki
|
||||
.as_ref()
|
||||
.map(|w| (w.config.toc_header.clone(), w.config.toc_header_level))
|
||||
@@ -342,27 +364,73 @@ fn toc_generate(backend: &Backend, args: Vec<Value>) -> Result<Option<WorkspaceE
|
||||
Ok(ops::toc_edit(
|
||||
&doc.text,
|
||||
&doc.ast,
|
||||
&p.uri,
|
||||
&uri,
|
||||
utf8,
|
||||
&header,
|
||||
level,
|
||||
margin,
|
||||
link_format,
|
||||
cursor_line,
|
||||
))
|
||||
}
|
||||
|
||||
fn links_generate(backend: &Backend, args: Vec<Value>) -> Result<Option<WorkspaceEdit>, String> {
|
||||
let p = parse_uri_arg(args)?;
|
||||
let doc = match backend.documents.get(&p.uri) {
|
||||
let (uri, cursor_line) = parse_uri_line_arg(args)?;
|
||||
links_generate_scoped(backend, &uri, cursor_line, None)
|
||||
}
|
||||
|
||||
/// `:VimwikiGenerateLinks <path>` — scope the generated links to a subtree.
|
||||
fn links_generate_for_path(
|
||||
backend: &Backend,
|
||||
args: Vec<Value>,
|
||||
) -> Result<Option<WorkspaceEdit>, String> {
|
||||
#[derive(Deserialize)]
|
||||
struct Args {
|
||||
uri: Url,
|
||||
path: String,
|
||||
#[serde(default)]
|
||||
line: Option<u32>,
|
||||
}
|
||||
let raw = args.into_iter().next().ok_or_else(|| {
|
||||
"nuwiki.links.generateForPath: missing { uri, path } argument".to_string()
|
||||
})?;
|
||||
let parsed: Args = serde_json::from_value(raw)
|
||||
.map_err(|e| format!("nuwiki.links.generateForPath: invalid args — {e}"))?;
|
||||
links_generate_scoped(backend, &parsed.uri, parsed.line, Some(&parsed.path))
|
||||
}
|
||||
|
||||
/// True when page `name` falls within `scope`. A scope containing `*`/`?` is a
|
||||
/// glob (upstream's pattern arg); otherwise it's a directory subtree — the page
|
||||
/// itself or anything under `<scope>/`. Page names are wiki-root-relative with
|
||||
/// `/` separators (e.g. `projects/api`).
|
||||
fn page_in_scope(name: &str, scope: &str) -> bool {
|
||||
let scope = scope.trim_matches('/');
|
||||
if scope.is_empty() {
|
||||
return true;
|
||||
}
|
||||
if scope.contains('*') || scope.contains('?') {
|
||||
crate::export::glob_match(scope, name)
|
||||
} else {
|
||||
name == scope || name.starts_with(&format!("{scope}/"))
|
||||
}
|
||||
}
|
||||
|
||||
fn links_generate_scoped(
|
||||
backend: &Backend,
|
||||
uri: &Url,
|
||||
cursor_line: Option<u32>,
|
||||
scope: Option<&str>,
|
||||
) -> Result<Option<WorkspaceEdit>, String> {
|
||||
let doc = match backend.documents.get(uri) {
|
||||
Some(d) => d,
|
||||
None => return Ok(None),
|
||||
};
|
||||
let utf8 = backend.use_utf8.load(Ordering::Relaxed);
|
||||
let wiki = match backend.wiki_for_uri(&p.uri) {
|
||||
let wiki = match backend.wiki_for_uri(uri) {
|
||||
Some(w) => w,
|
||||
None => return Ok(None),
|
||||
};
|
||||
let current_page = crate::index::page_name_from_uri(&p.uri, Some(&wiki.config.root));
|
||||
let current_page = crate::index::page_name_from_uri(uri, Some(&wiki.config.root));
|
||||
let (pages, captions) = {
|
||||
let idx = wiki
|
||||
.index
|
||||
@@ -372,12 +440,20 @@ fn links_generate(backend: &Backend, args: Vec<Value>) -> Result<Option<Workspac
|
||||
.config
|
||||
.generated_links_caption
|
||||
.then(|| ops::page_captions(&idx));
|
||||
(idx.page_names(), captions)
|
||||
let names: Vec<String> = match scope {
|
||||
Some(s) => idx
|
||||
.page_names()
|
||||
.into_iter()
|
||||
.filter(|n| page_in_scope(n, s))
|
||||
.collect(),
|
||||
None => idx.page_names(),
|
||||
};
|
||||
(names, captions)
|
||||
};
|
||||
Ok(ops::links_edit(
|
||||
&doc.text,
|
||||
&doc.ast,
|
||||
&p.uri,
|
||||
uri,
|
||||
¤t_page,
|
||||
&pages,
|
||||
utf8,
|
||||
@@ -385,6 +461,7 @@ fn links_generate(backend: &Backend, args: Vec<Value>) -> Result<Option<Workspac
|
||||
wiki.config.links_header_level,
|
||||
wiki.config.list_margin.max(0) as usize,
|
||||
captions.as_ref(),
|
||||
cursor_line,
|
||||
))
|
||||
}
|
||||
|
||||
@@ -621,6 +698,9 @@ fn tags_generate_links(
|
||||
uri: Url,
|
||||
#[serde(default)]
|
||||
tag: Option<String>,
|
||||
/// 0-based cursor line; a fresh tags section is inserted here.
|
||||
#[serde(default)]
|
||||
line: Option<u32>,
|
||||
}
|
||||
let raw = args
|
||||
.into_iter()
|
||||
@@ -653,6 +733,7 @@ fn tags_generate_links(
|
||||
&wiki.config.tags_header,
|
||||
wiki.config.tags_header_level,
|
||||
wiki.config.list_margin.max(0) as usize,
|
||||
parsed.line,
|
||||
))
|
||||
}
|
||||
|
||||
@@ -1359,6 +1440,32 @@ fn eof_position(text: &str) -> LspPosition {
|
||||
}
|
||||
}
|
||||
|
||||
/// Decide where to insert a generated section (TOC / generated links / tags
|
||||
/// index) and the text block to write. When the client passed a cursor
|
||||
/// `insert_line`, insert at that line (clamped to the document; a leading blank
|
||||
/// keeps the heading off the preceding line, except at the top of the file).
|
||||
/// Otherwise fall back to `fallback` — top of file for the TOC, end of file for
|
||||
/// the links/tags sections.
|
||||
fn section_insert(
|
||||
text: &str,
|
||||
insert_line: Option<u32>,
|
||||
fallback: LspPosition,
|
||||
new_text: &str,
|
||||
) -> (LspPosition, String) {
|
||||
match insert_line {
|
||||
Some(l) => {
|
||||
let line = l.min(text.matches('\n').count() as u32);
|
||||
let block = if line == 0 {
|
||||
format!("{new_text}\n")
|
||||
} else {
|
||||
format!("\n{new_text}\n")
|
||||
};
|
||||
(LspPosition { line, character: 0 }, block)
|
||||
}
|
||||
None => (fallback, format!("{new_text}\n")),
|
||||
}
|
||||
}
|
||||
|
||||
// ===== Pure operations =====
|
||||
|
||||
pub mod ops {
|
||||
@@ -2060,6 +2167,11 @@ pub mod ops {
|
||||
/// Produce a `WorkspaceEdit` that replaces or inserts the TOC for the
|
||||
/// given document. Returns `None` if the document has no headings at
|
||||
/// all (other than possibly a pre-existing TOC heading).
|
||||
///
|
||||
/// When there's no existing TOC section, a fresh one is inserted at
|
||||
/// `insert_line` (0-based) — the cursor line passed by the client — or at
|
||||
/// the top of the file when `None`. An existing TOC is always replaced in
|
||||
/// place, regardless of the cursor.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn toc_edit(
|
||||
text: &str,
|
||||
@@ -2070,6 +2182,7 @@ pub mod ops {
|
||||
level: u8,
|
||||
margin: usize,
|
||||
link_format: u8,
|
||||
insert_line: Option<u32>,
|
||||
) -> Option<WorkspaceEdit> {
|
||||
let items = collect_toc_items(ast, heading);
|
||||
if items.is_empty() {
|
||||
@@ -2087,17 +2200,13 @@ pub mod ops {
|
||||
b.edit(uri.clone(), text_edit_replace(span, new_text, text, utf8));
|
||||
}
|
||||
None => {
|
||||
let with_sep = format!("{new_text}\n");
|
||||
b.edit(
|
||||
uri.clone(),
|
||||
text_edit_insert(
|
||||
LspPosition {
|
||||
line: 0,
|
||||
character: 0,
|
||||
},
|
||||
with_sep,
|
||||
),
|
||||
);
|
||||
// Fresh TOC: at the cursor line, else the top of the file.
|
||||
let top = LspPosition {
|
||||
line: 0,
|
||||
character: 0,
|
||||
};
|
||||
let (pos, block) = section_insert(text, insert_line, top, &new_text);
|
||||
b.edit(uri.clone(), text_edit_insert(pos, block));
|
||||
}
|
||||
}
|
||||
Some(b.build())
|
||||
@@ -2119,7 +2228,19 @@ pub mod ops {
|
||||
link_format: u8,
|
||||
) -> Option<WorkspaceEdit> {
|
||||
find_section_range(ast, heading)?;
|
||||
toc_edit(text, ast, uri, utf8, heading, level, margin, link_format)
|
||||
// Rebuild-on-save only ever replaces an existing TOC in place, so the
|
||||
// insert line is irrelevant.
|
||||
toc_edit(
|
||||
text,
|
||||
ast,
|
||||
uri,
|
||||
utf8,
|
||||
heading,
|
||||
level,
|
||||
margin,
|
||||
link_format,
|
||||
None,
|
||||
)
|
||||
}
|
||||
|
||||
/// Produce a `WorkspaceEdit` that replaces or inserts the
|
||||
@@ -2136,6 +2257,7 @@ pub mod ops {
|
||||
level: u8,
|
||||
margin: usize,
|
||||
captions: Option<&std::collections::BTreeMap<String, String>>,
|
||||
insert_line: Option<u32>,
|
||||
) -> Option<WorkspaceEdit> {
|
||||
if all_pages.is_empty() {
|
||||
return None;
|
||||
@@ -2155,9 +2277,9 @@ pub mod ops {
|
||||
b.edit(uri.clone(), text_edit_replace(span, new_text, text, utf8));
|
||||
}
|
||||
None => {
|
||||
let with_sep = format!("{new_text}\n");
|
||||
let insert_pos = eof_position(text);
|
||||
b.edit(uri.clone(), text_edit_insert(insert_pos, with_sep));
|
||||
// Fresh section: at the cursor line, else the end of the file.
|
||||
let (pos, block) = section_insert(text, insert_line, eof_position(text), &new_text);
|
||||
b.edit(uri.clone(), text_edit_insert(pos, block));
|
||||
}
|
||||
}
|
||||
Some(b.build())
|
||||
@@ -2180,6 +2302,7 @@ pub mod ops {
|
||||
captions: Option<&std::collections::BTreeMap<String, String>>,
|
||||
) -> Option<WorkspaceEdit> {
|
||||
find_section_range(ast, heading)?;
|
||||
// Rebuild-on-save only ever replaces an existing section in place.
|
||||
links_edit(
|
||||
text,
|
||||
ast,
|
||||
@@ -2191,6 +2314,7 @@ pub mod ops {
|
||||
level,
|
||||
margin,
|
||||
captions,
|
||||
None,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -3413,6 +3537,7 @@ pub mod ops {
|
||||
header: &str,
|
||||
level: u8,
|
||||
margin: usize,
|
||||
insert_line: Option<u32>,
|
||||
) -> Option<WorkspaceEdit> {
|
||||
let body = build_tag_links_text(pages_by_tag, tag, header, level, margin)?;
|
||||
let heading = tag_section_heading(tag, header);
|
||||
@@ -3423,9 +3548,9 @@ pub mod ops {
|
||||
b.edit(uri.clone(), text_edit_replace(span, body, text, utf8));
|
||||
}
|
||||
None => {
|
||||
let with_sep = format!("{body}\n");
|
||||
let insert_pos = eof_position(text);
|
||||
b.edit(uri.clone(), text_edit_insert(insert_pos, with_sep));
|
||||
// Fresh section: at the cursor line, else the end of the file.
|
||||
let (pos, block) = section_insert(text, insert_line, eof_position(text), &body);
|
||||
b.edit(uri.clone(), text_edit_insert(pos, block));
|
||||
}
|
||||
}
|
||||
Some(b.build())
|
||||
@@ -3446,6 +3571,7 @@ pub mod ops {
|
||||
margin: usize,
|
||||
) -> Option<WorkspaceEdit> {
|
||||
find_section_range(ast, header)?;
|
||||
// Rebuild-on-save only ever replaces an existing section in place.
|
||||
tag_links_edit(
|
||||
text,
|
||||
ast,
|
||||
@@ -3456,6 +3582,7 @@ pub mod ops {
|
||||
header,
|
||||
level,
|
||||
margin,
|
||||
None,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -3871,3 +3998,33 @@ pub mod export_ops {
|
||||
out
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod scope_tests {
|
||||
use super::page_in_scope;
|
||||
|
||||
#[test]
|
||||
fn empty_scope_matches_everything() {
|
||||
assert!(page_in_scope("anything", ""));
|
||||
assert!(page_in_scope("a/b/c", "/"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn subtree_matches_self_and_descendants() {
|
||||
assert!(page_in_scope("projects", "projects"));
|
||||
assert!(page_in_scope("projects/api", "projects"));
|
||||
assert!(page_in_scope("projects/api/v2", "projects"));
|
||||
// Not a sibling that merely shares a prefix.
|
||||
assert!(!page_in_scope("projectsX", "projects"));
|
||||
assert!(!page_in_scope("Home", "projects"));
|
||||
// Trailing slash on the scope is tolerated.
|
||||
assert!(page_in_scope("projects/api", "projects/"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn glob_scope_uses_pattern_match() {
|
||||
assert!(page_in_scope("diary/2026-05-30", "diary/*"));
|
||||
assert!(page_in_scope("notes-2026", "notes-*"));
|
||||
assert!(!page_in_scope("Home", "diary/*"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -176,6 +176,10 @@ pub struct HtmlConfig {
|
||||
/// vimwiki `html_header_numbering_sym`: symbol appended after the
|
||||
/// section number (e.g. `.` → `1.2. Heading`). Empty by default.
|
||||
pub html_header_numbering_sym: String,
|
||||
/// vimwiki `toc_header`: the heading text that identifies the TOC
|
||||
/// section. When a heading matches this value (case-insensitive), it
|
||||
/// gets `class="toc"` in HTML export.
|
||||
pub toc_header: String,
|
||||
/// vimwiki `rss_name`: filename of the generated RSS feed (default
|
||||
/// `rss.xml`), relative to `html_path`.
|
||||
pub rss_name: String,
|
||||
@@ -224,6 +228,7 @@ impl Default for HtmlConfig {
|
||||
base_url: String::new(),
|
||||
html_header_numbering: 0,
|
||||
html_header_numbering_sym: String::new(),
|
||||
toc_header: default_toc_header(),
|
||||
rss_name: "rss.xml".into(),
|
||||
rss_max_items: 10,
|
||||
valid_html_tags: default_valid_html_tags(),
|
||||
@@ -634,14 +639,11 @@ impl Config {
|
||||
|
||||
let wikis = if let Some(list) = raw.wikis {
|
||||
list.into_iter().map(WikiConfig::from).collect()
|
||||
} else if let Some(root) = raw.wiki_root.as_deref().and_then(expand_path) {
|
||||
let mut wc = WikiConfig::from_root(root);
|
||||
if let Some(ext) = raw.file_extension {
|
||||
wc.file_extension = ext;
|
||||
}
|
||||
if let Some(s) = raw.syntax {
|
||||
wc.syntax = s;
|
||||
}
|
||||
} else if let Some(wc) = params
|
||||
.initialization_options
|
||||
.as_ref()
|
||||
.and_then(single_wiki_from_value)
|
||||
{
|
||||
vec![wc]
|
||||
} else if let Some(folder) = first_workspace_folder(params) {
|
||||
vec![WikiConfig::from_root(folder)]
|
||||
@@ -682,14 +684,7 @@ impl Config {
|
||||
};
|
||||
if let Some(list) = raw.wikis {
|
||||
self.wikis = list.into_iter().map(WikiConfig::from).collect();
|
||||
} else if let Some(root) = raw.wiki_root.as_deref().and_then(expand_path) {
|
||||
let mut wc = WikiConfig::from_root(root);
|
||||
if let Some(ext) = raw.file_extension {
|
||||
wc.file_extension = ext;
|
||||
}
|
||||
if let Some(s) = raw.syntax {
|
||||
wc.syntax = s;
|
||||
}
|
||||
} else if let Some(wc) = single_wiki_from_value(inner) {
|
||||
self.wikis = vec![wc];
|
||||
}
|
||||
// Only touch diagnostic settings when the payload carries them, so a
|
||||
@@ -954,6 +949,9 @@ impl From<RawWiki> for WikiConfig {
|
||||
if let Some(s) = r.html_header_numbering_sym {
|
||||
html.html_header_numbering_sym = s;
|
||||
}
|
||||
if let Some(ref s) = r.toc_header {
|
||||
html.toc_header = s.clone();
|
||||
}
|
||||
if let Some(s) = r.rss_name {
|
||||
html.rss_name = s;
|
||||
}
|
||||
@@ -1034,6 +1032,32 @@ impl From<RawWiki> for WikiConfig {
|
||||
}
|
||||
}
|
||||
|
||||
/// Build the single-wiki [`WikiConfig`] from the flat top-level options object
|
||||
/// (single-wiki shorthand: a `wiki_root` plus any per-wiki keys set alongside
|
||||
/// it). Returns `None` when there's no expandable `wiki_root` (so callers fall
|
||||
/// through to the workspace-folder default) or a `wikis` list is present.
|
||||
///
|
||||
/// Re-uses the full [`RawWiki`] schema so **every** per-wiki key set at the top
|
||||
/// level — `toc_header`/`toc_header_level`, `links_header`, `html_path`,
|
||||
/// `auto_export`, … — is honoured, not just `file_extension`/`syntax`. The
|
||||
/// shorthand keys on `wiki_root`; `RawWiki` keys on `root`, so we inject it.
|
||||
fn single_wiki_from_value(value: &serde_json::Value) -> Option<WikiConfig> {
|
||||
let obj = value.as_object()?;
|
||||
if obj.contains_key("wikis") {
|
||||
return None;
|
||||
}
|
||||
let wiki_root = obj.get("wiki_root").and_then(|v| v.as_str())?;
|
||||
// Bail (→ workspace-folder fallback) when the root can't be expanded.
|
||||
expand_path(wiki_root)?;
|
||||
let mut wiki_obj = obj.clone();
|
||||
wiki_obj.insert(
|
||||
"root".to_string(),
|
||||
serde_json::Value::String(wiki_root.to_string()),
|
||||
);
|
||||
let raw: RawWiki = serde_json::from_value(serde_json::Value::Object(wiki_obj)).ok()?;
|
||||
Some(WikiConfig::from(raw))
|
||||
}
|
||||
|
||||
fn expand_path(s: &str) -> Option<PathBuf> {
|
||||
let trimmed = s.trim();
|
||||
if trimmed.is_empty() {
|
||||
|
||||
+214
-17
@@ -227,17 +227,32 @@ pub fn render_page_html(
|
||||
}
|
||||
|
||||
let mut r = HtmlRenderer::new();
|
||||
if let Some(ext) = wiki_extension.filter(|e| !e.trim_start_matches('.').is_empty()) {
|
||||
{
|
||||
// Same-page anchor links (`[[#Section]]`) resolve to the *current*
|
||||
// page's html file plus the fragment (`index.html#Section`), matching
|
||||
// vimwiki — rather than a bare `#Section`.
|
||||
let current_html = format!("{}.html", name.rsplit('/').next().unwrap_or(name));
|
||||
// Strip the wiki extension from page links before the default resolver
|
||||
// turns them into `.html` URLs, so `[[todo.wiki]]` → `todo.html` rather
|
||||
// than `todo.wiki.html`. Only wiki/interwiki targets are touched;
|
||||
// file:/local: paths keep their literal extension.
|
||||
let ext = ext.to_string();
|
||||
let ext = wiki_extension
|
||||
.filter(|e| !e.trim_start_matches('.').is_empty())
|
||||
.map(|e| e.to_string());
|
||||
r = r.with_link_resolver(move |target| {
|
||||
if matches!(target.kind, LinkKind::AnchorOnly) {
|
||||
return match &target.anchor {
|
||||
Some(a) => format!("{current_html}#{a}"),
|
||||
None => current_html.clone(),
|
||||
};
|
||||
}
|
||||
let mut t = target.clone();
|
||||
if matches!(t.kind, LinkKind::Wiki | LinkKind::Interwiki) {
|
||||
if let Some(p) = t.path.take() {
|
||||
t.path = Some(crate::index::strip_wiki_extension(&p, Some(&ext)).to_string());
|
||||
if let Some(ext) = &ext {
|
||||
if matches!(t.kind, LinkKind::Wiki | LinkKind::Interwiki) {
|
||||
if let Some(p) = t.path.take() {
|
||||
t.path =
|
||||
Some(crate::index::strip_wiki_extension(&p, Some(ext)).to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
nuwiki_core::render::html::default_link_resolver(&t)
|
||||
@@ -259,6 +274,7 @@ pub fn render_page_html(
|
||||
cfg.html_header_numbering_sym.clone(),
|
||||
);
|
||||
}
|
||||
r = r.with_toc_header(&cfg.toc_header);
|
||||
if !cfg.valid_html_tags.is_empty() {
|
||||
r = r.with_valid_html_tags(cfg.valid_html_tags.clone());
|
||||
}
|
||||
@@ -328,7 +344,7 @@ fn escape_html(s: &str) -> String {
|
||||
/// Minimal glob match for `exclude_files`. Handles `*`, `**`, `?` and
|
||||
/// literal characters. No character classes — those aren't needed for
|
||||
/// "exclude foo/*.tmp" style patterns.
|
||||
fn glob_match(pattern: &str, text: &str) -> bool {
|
||||
pub(crate) fn glob_match(pattern: &str, text: &str) -> bool {
|
||||
glob_impl(pattern.as_bytes(), text.as_bytes())
|
||||
}
|
||||
|
||||
@@ -381,14 +397,195 @@ pub fn css_path(cfg: &HtmlConfig) -> PathBuf {
|
||||
cfg.html_path.join(&cfg.css_name)
|
||||
}
|
||||
|
||||
/// Minimal default CSS. Used by `nuwiki.export.*` commands when no CSS
|
||||
/// file exists yet at [`css_path`]. Deliberately tiny — users are
|
||||
/// expected to replace it with their own.
|
||||
pub const DEFAULT_CSS: &str =
|
||||
"body{font-family:sans-serif;max-width:46em;margin:2em auto;padding:0 1em;line-height:1.55}\
|
||||
h1,h2,h3,h4,h5,h6{font-family:serif;line-height:1.2}\
|
||||
pre{background:#f4f4f4;padding:.5em;overflow:auto}\
|
||||
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";
|
||||
/// Default CSS written by `nuwiki.export.*` commands when no CSS file exists
|
||||
/// yet at [`css_path`]. This is vimwiki's stock `style.css` verbatim (MIT
|
||||
/// licensed) so exports look identical to upstream out of the box and the
|
||||
/// `.header` / `.tag` / `.toc` / `done*` classes our HTML emits are styled.
|
||||
pub const DEFAULT_CSS: &str = r#"body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "Roboto", "Oxygen", "Ubuntu", "Cantarell", "Fira Sans", "Droid Sans", "Helvetica Neue", sans-serif;;
|
||||
margin: 2em 4em 2em 4em;
|
||||
font-size: 120%;
|
||||
line-height: 130%;
|
||||
}
|
||||
|
||||
h1, h2, h3, h4, h5, h6 {
|
||||
font-weight: bold;
|
||||
line-height:100%;
|
||||
margin-top: 1.5em;
|
||||
margin-bottom: 0.5em;
|
||||
}
|
||||
|
||||
h1 {font-size: 2em; color: #000000;}
|
||||
h2 {font-size: 1.8em; color: #404040;}
|
||||
h3 {font-size: 1.6em; color: #707070;}
|
||||
h4 {font-size: 1.4em; color: #909090;}
|
||||
h5 {font-size: 1.2em; color: #989898;}
|
||||
h6 {font-size: 1em; color: #9c9c9c;}
|
||||
|
||||
p, pre, blockquote, table, ul, ol, dl {
|
||||
margin-top: 1em;
|
||||
margin-bottom: 1em;
|
||||
}
|
||||
|
||||
ul ul, ul ol, ol ol, ol ul {
|
||||
margin-top: 0.5em;
|
||||
margin-bottom: 0.5em;
|
||||
}
|
||||
|
||||
li { margin: 0.3em auto; }
|
||||
|
||||
ul {
|
||||
margin-left: 2em;
|
||||
padding-left: 0;
|
||||
}
|
||||
|
||||
dt { font-weight: bold; }
|
||||
|
||||
img { border: none; }
|
||||
|
||||
pre {
|
||||
border-left: 5px solid #dcdcdc;
|
||||
background-color: #f5f5f5;
|
||||
padding-left: 1em;
|
||||
font-family: Monaco, "Courier New", "DejaVu Sans Mono", "Bitstream Vera Sans Mono", monospace;
|
||||
font-size: 0.8em;
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
p > a {
|
||||
color: white;
|
||||
text-decoration: none;
|
||||
font-size: 0.7em;
|
||||
padding: 3px 6px;
|
||||
border-radius: 3px;
|
||||
background-color: #1e90ff;
|
||||
text-transform: uppercase;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
p > a:hover {
|
||||
color: #dcdcdc;
|
||||
background-color: #484848;
|
||||
}
|
||||
|
||||
li > a {
|
||||
color: #1e90ff;
|
||||
font-weight: bold;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
li > a:hover { color: #ff4500; }
|
||||
|
||||
blockquote {
|
||||
color: #686868;
|
||||
font-size: 0.8em;
|
||||
line-height: 120%;
|
||||
padding: 0.8em;
|
||||
border-left: 5px solid #dcdcdc;
|
||||
}
|
||||
|
||||
th, td {
|
||||
border: 1px solid #ccc;
|
||||
padding: 0.3em;
|
||||
}
|
||||
|
||||
th { background-color: #f0f0f0; }
|
||||
|
||||
hr {
|
||||
border: none;
|
||||
border-top: 1px solid #ccc;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
del {
|
||||
text-decoration: line-through;
|
||||
color: #777777;
|
||||
}
|
||||
|
||||
.toc li { list-style-type: none; }
|
||||
|
||||
.todo {
|
||||
font-weight: bold;
|
||||
background-color: #ff4500 ;
|
||||
color: white;
|
||||
font-size: 0.8em;
|
||||
padding: 3px 6px;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.justleft { text-align: left; }
|
||||
.justright { text-align: right; }
|
||||
.justcenter { text-align: center; }
|
||||
|
||||
.center {
|
||||
margin-left: auto;
|
||||
margin-right: auto;
|
||||
}
|
||||
|
||||
.tag {
|
||||
background-color: #eeeeee;
|
||||
font-family: monospace;
|
||||
padding: 2px;
|
||||
}
|
||||
|
||||
.header a {
|
||||
text-decoration: none;
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
/* classes for items of todo lists */
|
||||
|
||||
.rejected {
|
||||
/* list-style: none; */
|
||||
background-image: url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAA8AAAAPCAMAAAAMCGV4AAAACXBIWXMAAADFAAAAxQEdzbqoAAAAB3RJTUUH4QgEFhAtuWgv9wAAAPZQTFRFmpqam5iYnJaWnJeXnpSUn5OTopCQpoqKpouLp4iIqIiIrYCAt3V1vW1tv2xsmZmZmpeXnpKS/x4e/x8f/yAg/yIi/yQk/yUl/yYm/ygo/ykp/yws/zAw/zIy/zMz/zQ0/zU1/zY2/zw8/0BA/0ZG/0pK/1FR/1JS/1NT/1RU/1VV/1ZW/1dX/1pa/15e/19f/2Zm/2lp/21t/25u/3R0/3p6/4CA/4GB/4SE/4iI/46O/4+P/52d/6am/6ur/66u/7Oz/7S0/7e3/87O/9fX/9zc/93d/+Dg/+vr/+3t/+/v//Dw//Ly//X1//f3//n5//z8////gzaKowAAAA90Uk5T/Pz8/Pz8/Pz8/Pz8/f39ppQKWQAAAAFiS0dEEnu8bAAAAACuSURBVAhbPY9ZF4FQFEZPSKbIMmWep4gMGTKLkIv6/3/GPbfF97b3w17rA0kQOPgvAeHW6uJ6+5h7HqLdwowgOzejXRXBdx6UdSquml4xuOMBHHNU0clTzeSUA6EhF8V8kqroluMiU6HKcuf4phGPr1o2q9kYZWwNq1qfRRmTaXpqsyjj17KkWCxKBUBgXWueHIyiAIg18gsse4KHkLF5IKIY10WQgv7fOy4ST34BRiopZ8WLNrgAAAAASUVORK5CYII=);
|
||||
background-repeat: no-repeat;
|
||||
background-position: 0 .2em;
|
||||
padding-left: 1.5em;
|
||||
}
|
||||
.done0 {
|
||||
/* list-style: none; */
|
||||
background-image: url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAA8AAAAPCAYAAAA71pVKAAAABHNCSVQICAgIfAhkiAAAAAlwSFlzAAAAxQAAAMUBHc26qAAAABl0RVh0U29mdHdhcmUAd3d3Lmlua3NjYXBlLm9yZ5vuPBoAAAA7SURBVCiR7dMxEgAgCANBI3yVRzF5KxNbW6wsuH7LQ2YKQK1mkswBVERYF5Os3UV3gwd/jF2SkXy66gAZkxS6BniubAAAAABJRU5ErkJggg==);
|
||||
background-repeat: no-repeat;
|
||||
background-position: 0 .2em;
|
||||
padding-left: 1.5em;
|
||||
}
|
||||
.done1 {
|
||||
background-image: url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAA8AAAAPCAYAAAA71pVKAAAABHNCSVQICAgIfAhkiAAAAAlwSFlzAAAAxQAAAMUBHc26qAAAABl0RVh0U29mdHdhcmUAd3d3Lmlua3NjYXBlLm9yZ5vuPBoAAABtSURBVCiR1ZO7DYAwDER9BDmTeZQMFXmUbGYpOjrEryA0wOvO8itOslFrJYAug5BMM4BeSkmjsrv3aVTa8p48Xw1JSkSsWVUFwD05IqS1tmYzk5zzae9jnVVVzGyXb8sALjse+euRkEzu/uirFomVIdDGOLjuAAAAAElFTkSuQmCC);
|
||||
background-repeat: no-repeat;
|
||||
background-position: 0 .15em;
|
||||
padding-left: 1.5em;
|
||||
}
|
||||
.done2 {
|
||||
background-image: url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAA8AAAAPCAYAAAA71pVKAAAABHNCSVQICAgIfAhkiAAAAAlwSFlzAAAAxQAAAMUBHc26qAAAABl0RVh0U29mdHdhcmUAd3d3Lmlua3NjYXBlLm9yZ5vuPBoAAAB1SURBVCiRzdO5DcAgDAVQGxjAYgTvxlDIu1FTIRYAp8qlFISkSH7l5kk+ZIwxKiI2mIyqWoeILYRgZ7GINDOLjnmF3VqklKCUMgTee2DmM661Qs55iI3Zm/1u5h9sm4ig9z4ERHTFzLyd4G4+nFlVrYg8+qoF/c0kdpeMsmcAAAAASUVORK5CYII=);
|
||||
background-repeat: no-repeat;
|
||||
background-position: 0 .15em;
|
||||
padding-left: 1.5em;
|
||||
}
|
||||
.done3 {
|
||||
background-image: url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAA8AAAAPCAYAAAA71pVKAAAABHNCSVQICAgIfAhkiAAAAAlwSFlzAAAAxQAAAMUBHc26qAAAABl0RVh0U29mdHdhcmUAd3d3Lmlua3NjYXBlLm9yZ5vuPBoAAABoSURBVCiR7dOxDcAgDATA/0DtUdiKoZC3YhLkHjkVKF3idJHiztKfvrHZWnOSE8Fx95RJzlprimJVnXktvXeY2S0SEZRSAAAbmxnGGKH2I5T+8VfxPhIReQSuuY3XyYWa3T2p6quvOgGrvSFGlewuUAAAAABJRU5ErkJggg==);
|
||||
background-repeat: no-repeat;
|
||||
background-position: 0 .15em;
|
||||
padding-left: 1.5em;
|
||||
}
|
||||
.done4 {
|
||||
background-image: url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABIAAAAQCAYAAAAbBi9cAAAABHNCSVQICAgIfAhkiAAAAAlwSFlzAAAAzgAAAM4BlP6ToAAAABl0RVh0U29mdHdhcmUAd3d3Lmlua3NjYXBlLm9yZ5vuPBoAAAIISURBVDiNnZQ9SFtRFMd/773kpTaGJoQk1im4VDpWQcTNODhkFBcVTCNCF0NWyeDiIIiCm82QoIMIUkHUxcFBg1SEQoZszSat6cdTn1qNue92CMbEr9Of5+vd8/3nPux/3O+f8h6ukUil3sVg0+M+4cFxk42/jH2wAqqqKSCSiPQdwcHHAnDHH9s/tN1h8V28ETdP+eU8fT9Nt62ancYdIPvJNtsu87bmjrJlrTDVM4RROJs1JrHPrD4Bar7A6cpc54iKOaTdJXCUI2UMVrQZ0Js7YPN18ECKkYNQcJe/OE/4dZsw7VqNXQMvHy3QZXQypQ6ycrtwDjf8aJ+PNEDSCzLpn7+m2pD8ZKHlKarYhy6XjEoCYGcN95qansQeA3fNdki+SaJZGTMQIOoL3W/Z89rxv+tokubNajlvk/vm+LFpF2XnUKZHI0I+QrI7Dw0OZTqdzUkpsM7mZTyfy5OPGyw1tK7AFSvmB/Ks8w8YwbUYbe6/3QEKv0vugfxWPnMLJun+d/kI/WLdizpNjMbAIKrhMF4OuwadBALqqs+RfInwUvuNi+fBd+wjogfogAFVRmffO02q01mZZ0HHdgXIzdz0QQLPezIQygX6llxNKKgOFARYCC49CqhoHIUTlss/Vx2phlYwjw8j1CAlfAiwQiJpiy7o1VHnsG5FISkoJu7Q/2YmmaV+i0ei7v38L2CBguSi5AAAAAElFTkSuQmCC);
|
||||
background-repeat: no-repeat;
|
||||
background-position: 0 .15em;
|
||||
padding-left: 1.5em;
|
||||
}
|
||||
|
||||
code {
|
||||
font-family: Monaco, "Courier New", "DejaVu Sans Mono", "Bitstream Vera Sans Mono", monospace;
|
||||
-webkit-border-radius: 1px;
|
||||
-moz-border-radius: 1px;
|
||||
border-radius: 1px;
|
||||
-moz-background-clip: padding;
|
||||
-webkit-background-clip: padding-box;
|
||||
background-clip: padding-box;
|
||||
padding: 0px 3px;
|
||||
display: inline-block;
|
||||
color: #52595d;
|
||||
border: 1px solid #ccc;
|
||||
background-color: #f9f9f9;
|
||||
}
|
||||
"#;
|
||||
|
||||
@@ -106,14 +106,11 @@ fn find_in_inlines(inlines: &[InlineNode], line: u32, col: u32) -> Option<&Inlin
|
||||
InlineNode::Superscript(s) => find_in_inlines(&s.children, line, col),
|
||||
InlineNode::Subscript(s) => find_in_inlines(&s.children, line, col),
|
||||
InlineNode::Color(c) => find_in_inlines(&c.children, line, col),
|
||||
InlineNode::WikiLink(w) => w
|
||||
.description
|
||||
.as_ref()
|
||||
.and_then(|d| find_in_inlines(d, line, col)),
|
||||
InlineNode::ExternalLink(e) => e
|
||||
.description
|
||||
.as_ref()
|
||||
.and_then(|d| find_in_inlines(d, line, col)),
|
||||
// Links are an atomic unit for navigation: a cursor anywhere in
|
||||
// `[[target|description]]` (or `[desc](url)`) should resolve the
|
||||
// link itself. Don't descend into the description — returning its
|
||||
// inner Text node leaves goto-definition/references/hover with
|
||||
// nothing to act on ("No locations found" when on the title).
|
||||
_ => None,
|
||||
};
|
||||
return Some(deeper.unwrap_or(n));
|
||||
|
||||
@@ -307,7 +307,7 @@ fn nuwiki_toc_generates_nested_contents() {
|
||||
let doc = parse(src);
|
||||
let uri = Url::from_file_path("/wiki/Page.wiki").unwrap();
|
||||
let edit =
|
||||
ops::toc_edit(src, &doc, &uri, true, "Contents", 1, 0, 0).expect("toc edit produced");
|
||||
ops::toc_edit(src, &doc, &uri, true, "Contents", 1, 0, 0, None).expect("toc edit produced");
|
||||
assert!(edit.changes.is_some() || edit.document_changes.is_some());
|
||||
|
||||
let toc = ops::build_toc_text(
|
||||
@@ -348,6 +348,7 @@ fn nuwiki_generate_links_lists_all_pages_excluding_current() {
|
||||
"Generated Links",
|
||||
1,
|
||||
0,
|
||||
None,
|
||||
None
|
||||
)
|
||||
.is_some());
|
||||
@@ -430,7 +431,8 @@ fn nuwiki_generate_tag_links_builds_section() {
|
||||
true,
|
||||
"Generated Tags",
|
||||
1,
|
||||
0
|
||||
0,
|
||||
None
|
||||
)
|
||||
.is_some());
|
||||
}
|
||||
|
||||
@@ -32,6 +32,10 @@ fn commands_list_advertises_link_helpers() {
|
||||
"nuwiki.link.pasteWikilink",
|
||||
"nuwiki.link.pasteUrl",
|
||||
"nuwiki.link.catUrl",
|
||||
// Both GenerateLinks forms must be advertised so coc/vim-lsp accept
|
||||
// them (the scoped form was previously sent but unregistered).
|
||||
"nuwiki.links.generate",
|
||||
"nuwiki.links.generateForPath",
|
||||
] {
|
||||
assert!(names.contains(&name), "missing: {name}");
|
||||
}
|
||||
|
||||
@@ -255,6 +255,7 @@ fn tag_links_edit_inserts_when_section_missing() {
|
||||
"Generated Tags",
|
||||
1,
|
||||
0,
|
||||
None,
|
||||
)
|
||||
.expect("got an edit");
|
||||
let te = &edit.changes.unwrap()[&uri][0];
|
||||
@@ -264,6 +265,37 @@ fn tag_links_edit_inserts_when_section_missing() {
|
||||
assert!(te.new_text.contains("[[Alpha]]"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tag_links_edit_inserts_at_cursor_line() {
|
||||
// A fresh tags section goes at the cursor line, not the EOF.
|
||||
let src = "= Notes =\nbody\nmore\n";
|
||||
let ast = parse(src);
|
||||
let uri = Url::parse("file:///tmp/p.wiki").unwrap();
|
||||
let mut snap = BTreeMap::new();
|
||||
snap.insert("release".to_string(), vec!["Alpha".into()]);
|
||||
let edit = ops::tag_links_edit(
|
||||
src,
|
||||
&ast,
|
||||
&uri,
|
||||
Some("release"),
|
||||
&snap,
|
||||
true,
|
||||
"Generated Tags",
|
||||
1,
|
||||
0,
|
||||
Some(1),
|
||||
)
|
||||
.expect("edit");
|
||||
let te = &edit.changes.unwrap()[&uri][0];
|
||||
assert_eq!(te.range.start.line, 1);
|
||||
assert_eq!(te.range.start.character, 0);
|
||||
assert!(
|
||||
te.new_text.starts_with("\n= Tag: release ="),
|
||||
"got: {:?}",
|
||||
te.new_text
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tag_links_edit_replaces_existing_section() {
|
||||
let src = "= Tag: release =\n- [[Stale]]\n\n= Other =\n";
|
||||
@@ -281,6 +313,7 @@ fn tag_links_edit_replaces_existing_section() {
|
||||
"Generated Tags",
|
||||
1,
|
||||
0,
|
||||
None,
|
||||
)
|
||||
.expect("edit");
|
||||
let te = &edit.changes.unwrap()[&uri][0];
|
||||
@@ -296,8 +329,19 @@ fn tag_links_edit_full_index_replaces_existing_tags_section() {
|
||||
let uri = Url::parse("file:///tmp/p.wiki").unwrap();
|
||||
let mut snap = BTreeMap::new();
|
||||
snap.insert("alpha".to_string(), vec!["P".into()]);
|
||||
let edit = ops::tag_links_edit(src, &ast, &uri, None, &snap, true, "Generated Tags", 1, 0)
|
||||
.expect("edit");
|
||||
let edit = ops::tag_links_edit(
|
||||
src,
|
||||
&ast,
|
||||
&uri,
|
||||
None,
|
||||
&snap,
|
||||
true,
|
||||
"Generated Tags",
|
||||
1,
|
||||
0,
|
||||
None,
|
||||
)
|
||||
.expect("edit");
|
||||
let te = &edit.changes.unwrap()[&uri][0];
|
||||
assert!(te.new_text.contains("== alpha =="));
|
||||
assert!(!te.new_text.contains("[[old]]"));
|
||||
@@ -352,7 +396,8 @@ fn tag_links_edit_returns_none_for_unknown_tag() {
|
||||
true,
|
||||
"Generated Tags",
|
||||
1,
|
||||
0
|
||||
0,
|
||||
None
|
||||
)
|
||||
.is_none());
|
||||
}
|
||||
|
||||
@@ -385,19 +385,27 @@ fn render_page_html_numbers_headers_when_enabled() {
|
||||
)
|
||||
.unwrap();
|
||||
assert!(
|
||||
html.contains("<h1 id=\"Intro\">1. Intro</h1>"),
|
||||
html.contains(
|
||||
"<div id=\"Intro\"><h1 id=\"Intro\" class=\"header\"><a href=\"#Intro\">1. Intro</a></h1></div>"
|
||||
),
|
||||
"got: {html}"
|
||||
);
|
||||
assert!(
|
||||
html.contains("<h2 id=\"Background\">1.1. Background</h2>"),
|
||||
html.contains(
|
||||
"<div id=\"Intro-Background\"><h2 id=\"Background\" class=\"header\"><a href=\"#Intro-Background\">1.1. Background</a></h2></div>"
|
||||
),
|
||||
"got: {html}"
|
||||
);
|
||||
assert!(
|
||||
html.contains("<h2 id=\"Methods\">1.2. Methods</h2>"),
|
||||
html.contains(
|
||||
"<div id=\"Intro-Methods\"><h2 id=\"Methods\" class=\"header\"><a href=\"#Intro-Methods\">1.2. Methods</a></h2></div>"
|
||||
),
|
||||
"got: {html}"
|
||||
);
|
||||
assert!(
|
||||
html.contains("<h1 id=\"Results\">2. Results</h1>"),
|
||||
html.contains(
|
||||
"<div id=\"Results\"><h1 id=\"Results\" class=\"header\"><a href=\"#Results\">2. Results</a></h1></div>"
|
||||
),
|
||||
"got: {html}"
|
||||
);
|
||||
}
|
||||
@@ -419,14 +427,45 @@ fn render_page_html_numbering_skips_headers_above_start_level() {
|
||||
)
|
||||
.unwrap();
|
||||
assert!(
|
||||
html.contains("<h1 id=\"Title\">Title</h1>"),
|
||||
html.contains(
|
||||
"<div id=\"Title\"><h1 id=\"Title\" class=\"header\"><a href=\"#Title\">Title</a></h1></div>"
|
||||
),
|
||||
"h1 unnumbered, got: {html}"
|
||||
);
|
||||
assert!(
|
||||
html.contains("<h2 id=\"Section\">1 Section</h2>"),
|
||||
html.contains(
|
||||
"<div id=\"Title-Section\"><h2 id=\"Section\" class=\"header\"><a href=\"#Title-Section\">1 Section</a></h2></div>"
|
||||
),
|
||||
"got: {html}"
|
||||
);
|
||||
assert!(
|
||||
html.contains(
|
||||
"<div id=\"Title-Section-Sub\"><h3 id=\"Sub\" class=\"header\"><a href=\"#Title-Section-Sub\">1.1 Sub</a></h3></div>"
|
||||
),
|
||||
"got: {html}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn render_page_html_same_page_anchor_uses_current_filename() {
|
||||
// vimwiki parity (#4): `[[#Section]]` resolves to `<page>.html#Section`,
|
||||
// not a bare `#Section`, where <page> is the file being rendered.
|
||||
let ast = parse("= Contents =\n\n[[#Contents]]\n");
|
||||
let c = cfg("/tmp/x");
|
||||
let html = export::render_page_html(
|
||||
&ast,
|
||||
None,
|
||||
"index",
|
||||
DiaryDate::from_ymd(2026, 5, 11).unwrap(),
|
||||
&c.html,
|
||||
None,
|
||||
HashMap::new(),
|
||||
)
|
||||
.unwrap();
|
||||
assert!(
|
||||
html.contains("<a href=\"index.html#Contents\">"),
|
||||
"got: {html}"
|
||||
);
|
||||
assert!(html.contains("<h3 id=\"Sub\">1.1 Sub</h3>"), "got: {html}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -443,8 +482,18 @@ fn render_page_html_no_header_numbering_by_default() {
|
||||
HashMap::new(),
|
||||
)
|
||||
.unwrap();
|
||||
assert!(html.contains("<h1 id=\"Intro\">Intro</h1>"), "got: {html}");
|
||||
assert!(html.contains("<h2 id=\"Sub\">Sub</h2>"), "got: {html}");
|
||||
assert!(
|
||||
html.contains(
|
||||
"<div id=\"Intro\"><h1 id=\"Intro\" class=\"header\"><a href=\"#Intro\">Intro</a></h1></div>"
|
||||
),
|
||||
"got: {html}"
|
||||
);
|
||||
assert!(
|
||||
html.contains(
|
||||
"<div id=\"Intro-Sub\"><h2 id=\"Sub\" class=\"header\"><a href=\"#Intro-Sub\">Sub</a></h2></div>"
|
||||
),
|
||||
"got: {html}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -152,6 +152,28 @@ fn v1_0_single_wiki_desugars_to_one_entry() {
|
||||
assert_eq!(cfg.wikis[0].syntax, "vimwiki");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn single_wiki_shorthand_honours_top_level_per_wiki_keys() {
|
||||
// Regression: a single-wiki user sets per-wiki keys at the top level
|
||||
// (alongside wiki_root); they must reach the synthesized wiki, not just
|
||||
// file_extension/syntax. Previously toc_header_level etc. were dropped.
|
||||
let cfg = config_from_json(json!({
|
||||
"wiki_root": "/tmp/vimwiki",
|
||||
"toc_header": "Table of Contents",
|
||||
"toc_header_level": 2,
|
||||
"links_header_level": 3,
|
||||
"auto_export": true,
|
||||
"html_path": "/tmp/out",
|
||||
}));
|
||||
assert_eq!(cfg.wikis.len(), 1);
|
||||
let w = &cfg.wikis[0];
|
||||
assert_eq!(w.toc_header, "Table of Contents");
|
||||
assert_eq!(w.toc_header_level, 2);
|
||||
assert_eq!(w.links_header_level, 3);
|
||||
assert!(w.html.auto_export);
|
||||
assert_eq!(w.html.html_path, PathBuf::from("/tmp/out"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn explicit_wikis_list_overrides_legacy_shape() {
|
||||
let cfg = config_from_json(json!({
|
||||
@@ -652,3 +674,20 @@ fn legacy_single_wiki_shape_picks_up_defaults() {
|
||||
assert_eq!(cfg.wikis[0].index, "index");
|
||||
assert_eq!(cfg.wikis[0].diary_frequency, "daily");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn vimwiki_compat_payload_sets_toc_level_per_wiki() {
|
||||
// The exact shape the Vim client emits when translating g:vimwiki_list +
|
||||
// g:vimwiki_toc_header_level=2 (drop-in vimwiki compat).
|
||||
let cfg = config_from_json(json!({
|
||||
"wiki_root": "~/vimwiki",
|
||||
"wikis": [
|
||||
{ "root": "/tmp/personal", "toc_header_level": 2, "html_header_numbering": 2 },
|
||||
{ "root": "/tmp/ifood", "toc_header_level": 2 },
|
||||
],
|
||||
}));
|
||||
assert_eq!(cfg.wikis.len(), 2);
|
||||
assert_eq!(cfg.wikis[0].toc_header_level, 2);
|
||||
assert_eq!(cfg.wikis[0].html.html_header_numbering, 2);
|
||||
assert_eq!(cfg.wikis[1].toc_header_level, 2);
|
||||
}
|
||||
|
||||
@@ -484,7 +484,8 @@ fn toc_edit_inserts_at_top_when_no_existing_toc() {
|
||||
let src = "= One =\n== Two ==\n";
|
||||
let ast = parse(src);
|
||||
let uri = Url::parse("file:///tmp/page.wiki").unwrap();
|
||||
let edit = ops::toc_edit(src, &ast, &uri, true, "Contents", 1, 0, 0).expect("got an edit");
|
||||
let edit =
|
||||
ops::toc_edit(src, &ast, &uri, true, "Contents", 1, 0, 0, None).expect("got an edit");
|
||||
let changes = edit.changes.expect("changes map");
|
||||
let edits = &changes[&uri];
|
||||
assert_eq!(edits.len(), 1);
|
||||
@@ -497,12 +498,47 @@ fn toc_edit_inserts_at_top_when_no_existing_toc() {
|
||||
assert!(te.new_text.contains("[[#One|One]]"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn toc_edit_inserts_at_cursor_line() {
|
||||
// A fresh TOC goes at the cursor line (0-based) the client sends.
|
||||
let src = "= One =\nbody text\n== Two ==\nmore\n";
|
||||
let ast = parse(src);
|
||||
let uri = Url::parse("file:///tmp/page.wiki").unwrap();
|
||||
// Cursor on line 1 ("body text").
|
||||
let edit = ops::toc_edit(src, &ast, &uri, true, "Contents", 1, 0, 0, Some(1)).expect("edit");
|
||||
let te = &edit.changes.unwrap()[&uri][0];
|
||||
// Zero-width insert at line 1, column 0.
|
||||
assert_eq!(te.range.start, te.range.end);
|
||||
assert_eq!(te.range.start.line, 1);
|
||||
assert_eq!(te.range.start.character, 0);
|
||||
// A leading blank separates it from the preceding text.
|
||||
assert!(
|
||||
te.new_text.starts_with("\n= Contents ="),
|
||||
"got: {:?}",
|
||||
te.new_text
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn toc_edit_cursor_line_clamped_and_existing_replaced_in_place() {
|
||||
// An existing TOC is replaced in place regardless of the cursor line.
|
||||
let src = "= Contents =\n- [[#stale|Stale]]\n\n= Real =\n";
|
||||
let ast = parse(src);
|
||||
let uri = Url::parse("file:///tmp/page.wiki").unwrap();
|
||||
let edit = ops::toc_edit(src, &ast, &uri, true, "Contents", 1, 0, 0, Some(99)).expect("edit");
|
||||
let te = &edit.changes.unwrap()[&uri][0];
|
||||
// Replacement starts at line 0 (the existing section), not the cursor.
|
||||
assert_eq!(te.range.start.line, 0);
|
||||
assert!(te.new_text.contains("[[#Real|Real]]"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn toc_edit_replaces_existing_toc() {
|
||||
let src = "= Contents =\n- [[#stale|Stale]]\n\n= Real =\n";
|
||||
let ast = parse(src);
|
||||
let uri = Url::parse("file:///tmp/page.wiki").unwrap();
|
||||
let edit = ops::toc_edit(src, &ast, &uri, true, "Contents", 1, 0, 0).expect("got an edit");
|
||||
let edit =
|
||||
ops::toc_edit(src, &ast, &uri, true, "Contents", 1, 0, 0, None).expect("got an edit");
|
||||
let changes = edit.changes.expect("changes map");
|
||||
let edits = &changes[&uri];
|
||||
let te = &edits[0];
|
||||
@@ -517,7 +553,7 @@ fn toc_edit_returns_none_for_empty_doc() {
|
||||
let src = "";
|
||||
let ast = parse(src);
|
||||
let uri = Url::parse("file:///tmp/empty.wiki").unwrap();
|
||||
assert!(ops::toc_edit(src, &ast, &uri, true, "Contents", 1, 0, 0).is_none());
|
||||
assert!(ops::toc_edit(src, &ast, &uri, true, "Contents", 1, 0, 0, None).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -561,6 +597,7 @@ fn links_edit_inserts_when_section_absent() {
|
||||
1,
|
||||
0,
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.expect("edit");
|
||||
let te = &edit.changes.unwrap()[&uri][0];
|
||||
@@ -569,6 +606,37 @@ fn links_edit_inserts_when_section_absent() {
|
||||
assert!(!te.new_text.contains("[[Home]]"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn links_edit_inserts_at_cursor_line() {
|
||||
// A fresh Generated Links section goes at the cursor line, not the EOF.
|
||||
let src = "= Home =\nbody\nmore\n";
|
||||
let ast = parse(src);
|
||||
let uri = Url::parse("file:///tmp/page.wiki").unwrap();
|
||||
let pages = vec!["A".into()];
|
||||
let edit = ops::links_edit(
|
||||
src,
|
||||
&ast,
|
||||
&uri,
|
||||
"Home",
|
||||
&pages,
|
||||
true,
|
||||
"Generated Links",
|
||||
1,
|
||||
0,
|
||||
None,
|
||||
Some(1),
|
||||
)
|
||||
.expect("edit");
|
||||
let te = &edit.changes.unwrap()[&uri][0];
|
||||
assert_eq!(te.range.start.line, 1);
|
||||
assert_eq!(te.range.start.character, 0);
|
||||
assert!(
|
||||
te.new_text.starts_with("\n= Generated Links ="),
|
||||
"got: {:?}",
|
||||
te.new_text
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn links_edit_replaces_when_section_present() {
|
||||
let src = "= Generated Links =\n- [[Stale]]\n\n= Real =\n";
|
||||
@@ -586,6 +654,7 @@ fn links_edit_replaces_when_section_present() {
|
||||
1,
|
||||
0,
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.expect("edit");
|
||||
let te = &edit.changes.unwrap()[&uri][0];
|
||||
@@ -609,6 +678,7 @@ fn links_edit_returns_none_for_empty_page_list() {
|
||||
"Generated Links",
|
||||
1,
|
||||
0,
|
||||
None,
|
||||
None
|
||||
)
|
||||
.is_none());
|
||||
|
||||
@@ -201,6 +201,18 @@ fn find_inline_at_returns_wikilink_under_cursor() {
|
||||
assert!(matches!(node, InlineNode::WikiLink(_)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn find_inline_at_returns_wikilink_when_cursor_on_description() {
|
||||
// Regression: pressing <CR> on the *title* part of a piped link used to
|
||||
// descend into the description's Text node, so goto-definition saw a Text
|
||||
// (not a WikiLink) and reported "No locations found".
|
||||
let src = "see [[Other|My Title]] now\n";
|
||||
let doc = parse(src);
|
||||
// Byte col 16 is inside "Title" (the description), past the '|' at 11.
|
||||
let node = find_inline_at(&doc, 0, 16).expect("wikilink at cursor");
|
||||
assert!(matches!(node, InlineNode::WikiLink(_)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn find_inline_at_returns_none_for_plain_text() {
|
||||
let src = "just text\n";
|
||||
|
||||
@@ -64,6 +64,12 @@ vim.g.nuwiki_diary_rel_path = 'diary'
|
||||
|
||||
require('nuwiki').setup({
|
||||
wiki_root = '$WIKI_DIR',
|
||||
-- Exercise the per-wiki display settings via the global shorthand: these
|
||||
-- fold into the single wiki, so :NuwikiTOC writes `== Contents ==` (level 2)
|
||||
-- and HTML export numbers headings.
|
||||
toc_header_level = 2,
|
||||
html_header_numbering = 2,
|
||||
html_header_numbering_sym = ' -',
|
||||
})
|
||||
|
||||
-- Surface log lines (the LSP logs via window/logMessage). Without this
|
||||
|
||||
@@ -24,6 +24,7 @@
|
||||
# NUWIKI_DEV_NO_SEED=1 ./development/start-vim-coc.sh # use WIKI_DIR as-is (no scratch seeding)
|
||||
# NUWIKI_DEV_SKIP_BUILD=1 ./development/start-vim-coc.sh # reuse the cached binary
|
||||
# NUWIKI_DEV_COC_JUMP=edit ./development/start-vim-coc.sh # change coc.preferences.jumpCommand
|
||||
# NUWIKI_DEV_VIMWIKI=1 ./development/start-vim-coc.sh # drive nuwiki from a g:vimwiki_list config (drop-in)
|
||||
#
|
||||
# The release binary is rebuilt on every launch so source changes always reach
|
||||
# the running LSP. `cargo build --release` is incremental, so this is a fast
|
||||
@@ -72,42 +73,43 @@ ensure_coc() {
|
||||
|
||||
write_coc_settings() {
|
||||
mkdir -p "$DEV_DIR"
|
||||
# Mirror what the vim-lsp client sends (autoload/nuwiki/lsp.vim s:settings()):
|
||||
# both `initializationOptions` (sent on initialize) and `settings.nuwiki`
|
||||
# (served via workspace/configuration). Without these the server falls back
|
||||
# to its own default wiki_root (~/vimwiki) instead of $WIKI_DIR, so it can't
|
||||
# resolve links (e.g. Notes shows as broken) or create-on-follow correctly.
|
||||
# No `languageserver.nuwiki` block here on purpose: this dogfoods nuwiki's
|
||||
# *programmatic* coc registration (autoload/nuwiki/lsp.vim → coc#config). The
|
||||
# plugin registers the server itself from g:nuwiki_* (set in the vimrc),
|
||||
# exactly the "no hand-written coc-settings.json" flow real users get. Only
|
||||
# the jump-command preference (the <CR>-opens-a-tab reproducer) lives here.
|
||||
cat > "$COC_SETTINGS" <<EOF
|
||||
{
|
||||
"coc.preferences.jumpCommand": "$COC_JUMP",
|
||||
"languageserver": {
|
||||
"nuwiki": {
|
||||
"command": "$REPO_ROOT/bin/nuwiki-ls",
|
||||
"filetypes": ["vimwiki"],
|
||||
"initializationOptions": {
|
||||
"wiki_root": "$WIKI_DIR",
|
||||
"file_extension": ".wiki",
|
||||
"syntax": "vimwiki",
|
||||
"log_level": "info",
|
||||
"diagnostic": { "link_severity": "warn" }
|
||||
},
|
||||
"settings": {
|
||||
"nuwiki": {
|
||||
"wiki_root": "$WIKI_DIR",
|
||||
"file_extension": ".wiki",
|
||||
"syntax": "vimwiki",
|
||||
"log_level": "info",
|
||||
"diagnostic": { "link_severity": "warn" }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
"coc.preferences.jumpCommand": "$COC_JUMP"
|
||||
}
|
||||
EOF
|
||||
}
|
||||
|
||||
write_vimrc() {
|
||||
mkdir -p "$DEV_DIR"
|
||||
|
||||
# Wiki config block. Default: native g:nuwiki_* (single wiki). With
|
||||
# NUWIKI_DEV_VIMWIKI=1: an upstream g:vimwiki_list config instead, to dogfood
|
||||
# the g:vimwiki_* drop-in translation (the path real vimwiki migrants use).
|
||||
local WIKI_CFG
|
||||
if [[ "${NUWIKI_DEV_VIMWIKI:-0}" == "1" ]]; then
|
||||
log "NUWIKI_DEV_VIMWIKI=1 — driving nuwiki from an upstream g:vimwiki_list config"
|
||||
WIKI_CFG="\" Upstream vimwiki config (drop-in). nuwiki translates these.
|
||||
let g:vimwiki_toc_header_level = 2
|
||||
let g:vimwiki_html_header_numbering = 2
|
||||
let g:vimwiki_html_header_numbering_sym = ' -'
|
||||
let g:vimwiki_list = [{'path': '${WIKI_DIR}', 'path_html': '${DEV_DIR}/html', 'ext': '.wiki'}]"
|
||||
else
|
||||
WIKI_CFG="let g:nuwiki_wiki_root = '${WIKI_DIR}'
|
||||
let g:nuwiki_file_extension = '.wiki'
|
||||
let g:nuwiki_diary_rel_path = 'diary'
|
||||
\" Display settings exercise the global shorthand: :NuwikiTOC writes
|
||||
\" \`== Contents ==\` (level 2) and HTML export numbers headings.
|
||||
let g:nuwiki_toc_header_level = 2
|
||||
let g:nuwiki_html_header_numbering = 2
|
||||
let g:nuwiki_html_header_numbering_sym = ' -'"
|
||||
fi
|
||||
|
||||
cat > "$VIMRC" <<EOF
|
||||
" start-vim-coc.sh: generated minimal config for nuwiki development (coc.nvim).
|
||||
" Regenerated on every launch; edit start-vim-coc.sh, not this file.
|
||||
@@ -131,11 +133,11 @@ let g:coc_config_home = '${DEV_DIR}'
|
||||
let g:coc_data_home = '${COC_DATA}'
|
||||
|
||||
let g:nuwiki_binary_path = '${REPO_ROOT}/bin/nuwiki-ls'
|
||||
let g:nuwiki_wiki_root = '${WIKI_DIR}'
|
||||
let g:nuwiki_file_extension = '.wiki'
|
||||
let g:nuwiki_syntax = 'vimwiki'
|
||||
let g:nuwiki_diary_rel_path = 'diary'
|
||||
let g:nuwiki_log_level = 'info'
|
||||
" nuwiki auto-registers itself with coc from these globals (no coc-settings.json
|
||||
" languageserver block).
|
||||
${WIKI_CFG}
|
||||
|
||||
filetype plugin indent on
|
||||
syntax enable
|
||||
|
||||
@@ -87,6 +87,12 @@ let g:nuwiki_wiki_root = '${WIKI_DIR}'
|
||||
let g:nuwiki_file_extension = '.wiki'
|
||||
let g:nuwiki_syntax = 'vimwiki'
|
||||
let g:nuwiki_log_level = 'info'
|
||||
" Exercise the per-wiki display settings via the global shorthand: these fold
|
||||
" into the single wiki, so :NuwikiTOC writes `== Contents ==` (level 2) and
|
||||
" HTML export numbers headings.
|
||||
let g:nuwiki_toc_header_level = 2
|
||||
let g:nuwiki_html_header_numbering = 2
|
||||
let g:nuwiki_html_header_numbering_sym = ' -'
|
||||
|
||||
filetype plugin indent on
|
||||
syntax enable
|
||||
|
||||
@@ -89,6 +89,24 @@ call s:record(winnr('$') == s:wins_before - 1, 'action.window_count_decremented'
|
||||
call s:record(&filetype ==# 'vimwiki', 'action.sets_filetype',
|
||||
\ 'filetype=' . &filetype)
|
||||
|
||||
" ===== calendar follows the current wiki (multi-wiki) =====
|
||||
" Two wikis, each with its own diary entry. track_wiki() (driven by the
|
||||
" BufEnter autocmd in real use) switches which wiki the calendar reads.
|
||||
let s:wA = tempname() | call mkdir(s:wA . '/diary', 'p')
|
||||
let s:wB = tempname() | call mkdir(s:wB . '/diary', 'p')
|
||||
call writefile(['a'], s:wA . '/diary/2026-06-02.wiki')
|
||||
call writefile(['b'], s:wB . '/diary/2026-06-10.wiki')
|
||||
let g:nuwiki_wikis = [{'root': s:wA}, {'root': s:wB}]
|
||||
|
||||
call nuwiki#diary#track_wiki(s:wA . '/index.wiki')
|
||||
call s:record(nuwiki#diary#calendar_sign(2, 6, 2026) ==# '*', 'track.wikiA_own_entry', '')
|
||||
call s:record(nuwiki#diary#calendar_sign(10, 6, 2026) ==# '', 'track.wikiA_not_wikiB_entry', '')
|
||||
|
||||
call nuwiki#diary#track_wiki(s:wB . '/index.wiki')
|
||||
call s:record(nuwiki#diary#calendar_sign(10, 6, 2026) ==# '*', 'track.wikiB_own_entry', '')
|
||||
call s:record(nuwiki#diary#calendar_sign(2, 6, 2026) ==# '', 'track.wikiB_not_wikiA_entry', '')
|
||||
unlet g:nuwiki_wikis
|
||||
|
||||
" ===== Wrap up =====
|
||||
|
||||
call add(s:results, '')
|
||||
|
||||
Executable
+71
@@ -0,0 +1,71 @@
|
||||
#!/usr/bin/env bash
|
||||
#
|
||||
# development/tests/test-coc-register-vim.sh — programmatic coc.nvim
|
||||
# registration for the Vim client.
|
||||
#
|
||||
# nuwiki#lsp#start() registers the language server with coc via coc#config so
|
||||
# users don't hand-maintain coc-settings.json. This harness stubs coc#config
|
||||
# (a real autoload/coc.vim on the runtimepath, since Vim's exists('*coc#config')
|
||||
# goes through the autoload mechanism) to record what nuwiki injects, then runs
|
||||
# test-coc-register-vim.vim and asserts the payload.
|
||||
#
|
||||
# Exit code: 0 when every check passes, 1 on any FAIL or missing output.
|
||||
# Skips (exit 0) when vim is not installed.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
|
||||
|
||||
log() { printf '\033[1;34m[coc-register]\033[0m %s\n' "$*"; }
|
||||
|
||||
if ! command -v vim >/dev/null 2>&1; then
|
||||
log 'vim not installed — skipping'
|
||||
exit 0
|
||||
fi
|
||||
|
||||
TMP="$(mktemp -d -t nuwiki-coc-test-XXXXXX)"
|
||||
trap 'rm -rf "$TMP"' EXIT
|
||||
|
||||
# Stub coc#config as a real autoload function so exists('*coc#config') resolves
|
||||
# it. It records the last call into g:_recorded for the test to assert.
|
||||
mkdir -p "$TMP/stub/autoload"
|
||||
cat > "$TMP/stub/autoload/coc.vim" <<'EOF'
|
||||
function! coc#config(section, value) abort
|
||||
let g:_recorded = {'section': a:section, 'value': deepcopy(a:value)}
|
||||
endfunction
|
||||
EOF
|
||||
|
||||
OUT="$TMP/coc.out"
|
||||
VIMRC="$TMP/vimrc"
|
||||
cat > "$VIMRC" <<EOF
|
||||
set nocompatible
|
||||
" Stub dir first so its autoload/coc.vim is the one that resolves.
|
||||
let &runtimepath = '$TMP/stub' . ',' . '$REPO_ROOT' . ',' . &runtimepath
|
||||
" coc.nvim defines :CocConfig at startup; nuwiki uses that as the reliable
|
||||
" 'coc is present' signal. Stub it so the coc branch is taken.
|
||||
command! -nargs=0 CocConfig echo ''
|
||||
EOF
|
||||
|
||||
log 'running Vim coc-registration harness…'
|
||||
NUWIKI_COC_OUT="$OUT" \
|
||||
timeout 30 vim -e -s -u "$VIMRC" \
|
||||
-c "source $REPO_ROOT/development/tests/test-coc-register-vim.vim" \
|
||||
</dev/null >"$TMP/vim.log" 2>&1 || true
|
||||
|
||||
if [[ ! -f "$OUT" ]]; then
|
||||
echo 'coc-registration harness produced no output' >&2
|
||||
echo '--- vim log ---' >&2
|
||||
cat "$TMP/vim.log" >&2 || true
|
||||
exit 1
|
||||
fi
|
||||
|
||||
cat "$OUT"
|
||||
PASSED="$(grep -c '^PASS ' "$OUT" || true)"
|
||||
FAILED="$(grep -c '^FAIL ' "$OUT" || true)"
|
||||
echo "SUMMARY: ${PASSED} passed, ${FAILED} failed"
|
||||
|
||||
if [[ "$FAILED" -ne 0 ]]; then
|
||||
exit 1
|
||||
fi
|
||||
log 'coc registration OK ✓'
|
||||
exit 0
|
||||
@@ -0,0 +1,42 @@
|
||||
" development/tests/test-coc-register-vim.vim — programmatic coc.nvim
|
||||
" registration. Relies on a stub autoload/coc.vim (created by the wrapper) that
|
||||
" records coc#config calls into g:_recorded. Drives nuwiki#lsp#start()'s coc
|
||||
" branch and asserts the exact languageserver config nuwiki injects (command,
|
||||
" filetypes, and the s:settings() payload with the global shorthand folded in).
|
||||
" Writes PASS/FAIL to $NUWIKI_COC_OUT.
|
||||
|
||||
let s:out = []
|
||||
function! s:check(desc, cond) abort
|
||||
call add(s:out, (a:cond ? 'PASS ' : 'FAIL ') . a:desc)
|
||||
endfunction
|
||||
|
||||
" Pretend coc's node service is already up so registration runs synchronously.
|
||||
let g:coc_service_initialized = 1
|
||||
let g:_recorded = {}
|
||||
|
||||
" Make sure the vim-lsp branch is NOT taken.
|
||||
if exists('g:lsp_loaded') | unlet g:lsp_loaded | endif
|
||||
|
||||
" A readable, executable 'binary' so nuwiki#lsp#start() gets past its check.
|
||||
let g:nuwiki_binary_path = executable('true') ? exepath('true') : '/usr/bin/true'
|
||||
|
||||
" Config: single-wiki shorthand + a global display setting that must fold into
|
||||
" the wiki the server sees (the global shorthand).
|
||||
let g:nuwiki_wiki_root = '/tmp/realwiki'
|
||||
let g:nuwiki_toc_header_level = 2
|
||||
|
||||
call nuwiki#lsp#start()
|
||||
|
||||
let s:r = get(g:, '_recorded', {})
|
||||
call s:check('coc#config was called', !empty(s:r))
|
||||
call s:check('section is languageserver.nuwiki', get(s:r, 'section', '') ==# 'languageserver.nuwiki')
|
||||
let s:v = get(s:r, 'value', {})
|
||||
call s:check('command is the binary', get(s:v, 'command', '') ==# g:nuwiki_binary_path)
|
||||
call s:check('filetypes = [vimwiki]', get(s:v, 'filetypes', []) == ['vimwiki'])
|
||||
let s:io = get(s:v, 'initializationOptions', {})
|
||||
call s:check('initOpts has wiki_root', get(s:io, 'wiki_root', '') ==# '/tmp/realwiki')
|
||||
call s:check('global toc_header_level folded into payload', get(s:io, 'toc_header_level', 0) == 2)
|
||||
call s:check('settings.nuwiki mirrors initOpts', get(get(s:v, 'settings', {}), 'nuwiki', {}) ==# s:io)
|
||||
|
||||
call writefile(s:out, $NUWIKI_COC_OUT)
|
||||
qa!
|
||||
@@ -0,0 +1,87 @@
|
||||
-- development/tests/test-global-shorthand.lua — Neovim client global
|
||||
-- shorthand: a per-wiki setting given once at the top level (setup() or
|
||||
-- g:nuwiki_<key>) is folded into every wiki as a default, and a per-wiki
|
||||
-- value overrides it. Mirror of the Vim test-vimwiki-compat checks.
|
||||
--
|
||||
-- Writes PASS/FAIL lines to $NUWIKI_GS_OUT; the wrapper fails on any FAIL.
|
||||
|
||||
local config = require('nuwiki.config')
|
||||
local lsp = require('nuwiki.lsp')
|
||||
|
||||
local out = {}
|
||||
local function check(desc, cond)
|
||||
table.insert(out, (cond and 'PASS ' or 'FAIL ') .. desc)
|
||||
end
|
||||
|
||||
-- ----- setup() top-level scalar folds into every wiki; per-wiki wins -----
|
||||
config.apply({
|
||||
toc_header_level = 2,
|
||||
html_header_numbering = 2,
|
||||
wikis = {
|
||||
{ root = '/tmp/one', name = 'One' },
|
||||
{ root = '/tmp/two', name = 'Two', toc_header_level = 3 },
|
||||
},
|
||||
})
|
||||
local io1 = lsp.init_options()
|
||||
check('w0 inherits global level', io1.wikis[1].toc_header_level == 2)
|
||||
check('w0 inherits numbering', io1.wikis[1].html_header_numbering == 2)
|
||||
check('w1 per-wiki override wins', io1.wikis[2].toc_header_level == 3)
|
||||
check('w1 still inherits numbering', io1.wikis[2].html_header_numbering == 2)
|
||||
|
||||
-- The user's setup table must not be mutated by the fold.
|
||||
check('no mutation of user wikis', config.user.wikis[1].toc_header_level == nil)
|
||||
|
||||
-- ----- built-in defaults are NOT folded (only explicit user values) -----
|
||||
config.apply({
|
||||
wikis = { { root = '/tmp/x', name = 'X' } },
|
||||
})
|
||||
local io2 = lsp.init_options()
|
||||
check('default toc_header not folded', io2.wikis[1].toc_header == nil)
|
||||
check('default level not folded', io2.wikis[1].toc_header_level == nil)
|
||||
|
||||
-- ----- g:nuwiki_<key> acts as a global too -----
|
||||
config.apply({
|
||||
wikis = { { root = '/tmp/y', name = 'Y' } },
|
||||
})
|
||||
vim.g.nuwiki_toc_header_level = 5
|
||||
local io3 = lsp.init_options()
|
||||
check('g:nuwiki_ global folds', io3.wikis[1].toc_header_level == 5)
|
||||
vim.g.nuwiki_toc_header_level = nil
|
||||
|
||||
-- ----- upstream vim.g.vimwiki_list drop-in (the lazy.nvim vimwiki-swap case) --
|
||||
config.apply({}) -- setup() with no native config
|
||||
vim.g.vimwiki_list = {
|
||||
{ path = '~/.vimwiki/personal', path_html = '~/public_html/personal', auto_export = 1 },
|
||||
{ path = '~/.vimwiki/work' },
|
||||
}
|
||||
vim.g.vimwiki_toc_header_level = 2
|
||||
local io4 = lsp.init_options()
|
||||
check('vimwiki_list -> 2 wikis', io4.wikis ~= nil and #io4.wikis == 2)
|
||||
check('path -> root', io4.wikis[1].root == '~/.vimwiki/personal')
|
||||
check('path_html -> html_path', io4.wikis[1].html_path == '~/public_html/personal')
|
||||
check('vimwiki global folds', io4.wikis[1].toc_header_level == 2)
|
||||
check('w2 root', io4.wikis[2].root == '~/.vimwiki/work')
|
||||
-- Buffer-side commands (<Leader>ww) resolve the same wikis.
|
||||
check('wiki_list reads vimwiki_list', config.wiki_list()[1].root == '~/.vimwiki/personal')
|
||||
-- Native g:nuwiki_wikis wins over g:vimwiki_list.
|
||||
vim.g.nuwiki_wikis = { { root = '~/native', name = 'native' } }
|
||||
local io5 = lsp.init_options()
|
||||
check('native g:nuwiki_wikis wins', #io5.wikis == 1 and io5.wikis[1].root == '~/native')
|
||||
vim.g.nuwiki_wikis = nil
|
||||
vim.g.vimwiki_list = nil
|
||||
vim.g.vimwiki_toc_header_level = nil
|
||||
|
||||
-- ----- setup({ wikis }) bridges the Lua config to VimL (g:nuwiki_wikis) -----
|
||||
-- So buffer-side VimL helpers + the vimwiki-sync compat shim see the same
|
||||
-- wikis as the LSP, even though setup() config lives only in Lua.
|
||||
require('nuwiki').setup({ wikis = { { root = '/tmp/a', name = 'A' }, { root = '/tmp/b' } } })
|
||||
check('setup() bridges to g:nuwiki_wikis',
|
||||
vim.g.nuwiki_wikis ~= nil and #vim.g.nuwiki_wikis == 2)
|
||||
check('bridged wiki root visible to VimL',
|
||||
vim.g.nuwiki_wikis and vim.g.nuwiki_wikis[1].root == '/tmp/a')
|
||||
vim.g.nuwiki_wikis = nil
|
||||
|
||||
local f = assert(io.open(os.getenv('NUWIKI_GS_OUT'), 'w'))
|
||||
f:write(table.concat(out, '\n') .. '\n')
|
||||
f:close()
|
||||
vim.cmd('qa!')
|
||||
Executable
+54
@@ -0,0 +1,54 @@
|
||||
#!/usr/bin/env bash
|
||||
#
|
||||
# development/tests/test-global-shorthand.sh — Neovim client global-shorthand
|
||||
# folding (vimwiki-style per-wiki defaults). Runs test-global-shorthand.lua
|
||||
# under headless Neovim; that script asserts the init_options payload folds a
|
||||
# top-level / g:nuwiki_* setting into every wiki, lets a per-wiki value
|
||||
# override it, and does NOT fold built-in defaults.
|
||||
#
|
||||
# Exit code: 0 when every check passes, 1 on any FAIL or missing output.
|
||||
# Skips (exit 0) when nvim is not installed.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
|
||||
|
||||
log() { printf '\033[1;34m[global-shorthand]\033[0m %s\n' "$*"; }
|
||||
|
||||
if ! command -v nvim >/dev/null 2>&1; then
|
||||
log 'nvim not installed — skipping'
|
||||
exit 0
|
||||
fi
|
||||
|
||||
TMP="$(mktemp -d -t nuwiki-gs-test-XXXXXX)"
|
||||
trap 'rm -rf "$TMP"' EXIT
|
||||
|
||||
OUT="$TMP/gs.out"
|
||||
INIT="$TMP/init.lua"
|
||||
cat > "$INIT" <<EOF
|
||||
vim.opt.runtimepath:prepend('$REPO_ROOT')
|
||||
EOF
|
||||
|
||||
log 'running Neovim global-shorthand harness…'
|
||||
NUWIKI_GS_OUT="$OUT" \
|
||||
timeout 30 nvim --clean -u "$INIT" --headless \
|
||||
-c "luafile $REPO_ROOT/development/tests/test-global-shorthand.lua" \
|
||||
>"$TMP/nvim.log" 2>&1 || true
|
||||
|
||||
if [[ ! -f "$OUT" ]]; then
|
||||
echo 'global-shorthand harness produced no output' >&2
|
||||
echo '--- nvim log ---' >&2
|
||||
cat "$TMP/nvim.log" >&2 || true
|
||||
exit 1
|
||||
fi
|
||||
|
||||
cat "$OUT"
|
||||
PASSED="$(grep -c '^PASS ' "$OUT" || true)"
|
||||
FAILED="$(grep -c '^FAIL ' "$OUT" || true)"
|
||||
echo "SUMMARY: ${PASSED} passed, ${FAILED} failed"
|
||||
|
||||
if [[ "$FAILED" -ne 0 ]]; then
|
||||
exit 1
|
||||
fi
|
||||
log 'global shorthand OK ✓'
|
||||
exit 0
|
||||
Executable
+59
@@ -0,0 +1,59 @@
|
||||
#!/usr/bin/env bash
|
||||
#
|
||||
# development/tests/test-vimwiki-compat-vim.sh — upstream-vimwiki config
|
||||
# drop-in compatibility for the Vim client.
|
||||
#
|
||||
# Runs test-vimwiki-compat-vim.vim under headless Vim; that script sets an
|
||||
# upstream `g:vimwiki_list` + `g:vimwiki_*` config and asserts
|
||||
# nuwiki#lsp#settings() translates it into the server's schema (per-wiki key
|
||||
# remap + global scalar settings folded into each wiki). Also checks that
|
||||
# nuwiki-native config takes precedence.
|
||||
#
|
||||
# Exit code: 0 when every check passes, 1 on any FAIL or missing output.
|
||||
# Skips (exit 0) when vim is not installed.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
|
||||
|
||||
log() { printf '\033[1;34m[vimwiki-compat]\033[0m %s\n' "$*"; }
|
||||
|
||||
if ! command -v vim >/dev/null 2>&1; then
|
||||
log 'vim not installed — skipping'
|
||||
exit 0
|
||||
fi
|
||||
|
||||
TMP="$(mktemp -d -t nuwiki-vwc-test-XXXXXX)"
|
||||
trap 'rm -rf "$TMP"' EXIT
|
||||
|
||||
OUT="$TMP/vwc.out"
|
||||
|
||||
VIMRC="$TMP/vimrc"
|
||||
cat > "$VIMRC" <<EOF
|
||||
set nocompatible
|
||||
let &runtimepath = '$REPO_ROOT' . ',' . &runtimepath
|
||||
EOF
|
||||
|
||||
log 'running Vim vimwiki-compat harness…'
|
||||
NUWIKI_VWC_OUT="$OUT" \
|
||||
timeout 30 vim -e -s -u "$VIMRC" \
|
||||
-c "source $REPO_ROOT/development/tests/test-vimwiki-compat-vim.vim" \
|
||||
</dev/null >"$TMP/vim.log" 2>&1 || true
|
||||
|
||||
if [[ ! -f "$OUT" ]]; then
|
||||
echo 'vimwiki-compat harness produced no output' >&2
|
||||
echo '--- vim log ---' >&2
|
||||
cat "$TMP/vim.log" >&2 || true
|
||||
exit 1
|
||||
fi
|
||||
|
||||
cat "$OUT"
|
||||
PASSED="$(grep -c '^PASS ' "$OUT" || true)"
|
||||
FAILED="$(grep -c '^FAIL ' "$OUT" || true)"
|
||||
echo "SUMMARY: ${PASSED} passed, ${FAILED} failed"
|
||||
|
||||
if [[ "$FAILED" -ne 0 ]]; then
|
||||
exit 1
|
||||
fi
|
||||
log 'vimwiki config compat OK ✓'
|
||||
exit 0
|
||||
@@ -0,0 +1,86 @@
|
||||
" development/tests/test-vimwiki-compat-vim.vim — upstream-vimwiki config
|
||||
" drop-in compatibility for the Vim client.
|
||||
"
|
||||
" Sourced by test-vimwiki-compat-vim.sh under headless Vim. Sets an upstream
|
||||
" `g:vimwiki_list` + `g:vimwiki_*` config and asserts nuwiki#lsp#settings()
|
||||
" translates it into the server's schema. Writes PASS/FAIL to
|
||||
" $NUWIKI_VWC_OUT; the wrapper fails on any FAIL.
|
||||
|
||||
let s:out = []
|
||||
function! s:check(desc, cond) abort
|
||||
call add(s:out, (a:cond ? 'PASS ' : 'FAIL ') . a:desc)
|
||||
endfunction
|
||||
|
||||
" ----- upstream vimwiki config (no nuwiki-native vars set) -----
|
||||
let g:vimwiki_toc_header_level = 2
|
||||
let g:vimwiki_html_header_numbering = 2
|
||||
let g:vimwiki_html_header_numbering_sym = ' -'
|
||||
let s:personal = {}
|
||||
let s:personal.path = '~/.vimwiki/personal_wiki'
|
||||
let s:personal.path_html = '~/public_html/personal_wiki'
|
||||
let s:personal.template_default = 'default'
|
||||
let s:personal.template_ext = '.tpl'
|
||||
let s:personal.auto_export = 1
|
||||
let s:ifood = {}
|
||||
let s:ifood.path = '~/.vimwiki/ifood_wiki'
|
||||
let g:vimwiki_list = [s:personal, s:ifood]
|
||||
|
||||
let s:cfg = nuwiki#lsp#settings()
|
||||
|
||||
call s:check('has wikis list', has_key(s:cfg, 'wikis'))
|
||||
call s:check('two wikis', has_key(s:cfg, 'wikis') && len(s:cfg.wikis) == 2)
|
||||
let s:w0 = get(s:cfg, 'wikis', [{}])[0]
|
||||
let s:w1 = get(s:cfg, 'wikis', [{},{}])[1]
|
||||
call s:check('w0 path->root', get(s:w0, 'root', '') ==# '~/.vimwiki/personal_wiki')
|
||||
call s:check('w0 path_html->html_path', get(s:w0, 'html_path', '') ==# '~/public_html/personal_wiki')
|
||||
call s:check('w0 template_default', get(s:w0, 'template_default', '') ==# 'default')
|
||||
call s:check('w0 auto_export', get(s:w0, 'auto_export', 0) == 1)
|
||||
call s:check('w0 toc_header_level=2', get(s:w0, 'toc_header_level', 0) == 2)
|
||||
call s:check('w0 html_numbering=2', get(s:w0, 'html_header_numbering', 0) == 2)
|
||||
call s:check('w0 numbering_sym', get(s:w0, 'html_header_numbering_sym', '') ==# ' -')
|
||||
call s:check('w1 root', get(s:w1, 'root', '') ==# '~/.vimwiki/ifood_wiki')
|
||||
call s:check('w1 globals applied', get(s:w1, 'toc_header_level', 0) == 2)
|
||||
|
||||
" The buffer-side commands (e.g. <Leader>ww) must resolve the SAME wikis from
|
||||
" g:vimwiki_list — not just the LSP payload. Regression guard for "<Leader>ww
|
||||
" opens an empty ~/vimwiki".
|
||||
let s:wl = nuwiki#commands#wiki_list()
|
||||
call s:check('wiki_list sees vimwiki config', len(s:wl) == 2)
|
||||
call s:check('wiki_list w0 root', len(s:wl) >= 1 && s:wl[0].root ==# expand('~/.vimwiki/personal_wiki'))
|
||||
call s:check('wiki_list w1 root', len(s:wl) >= 2 && s:wl[1].root ==# expand('~/.vimwiki/ifood_wiki'))
|
||||
|
||||
" ----- nuwiki-native config wins over vimwiki -----
|
||||
let g:nuwiki_wikis = [{'root': '~/native', 'name': 'native'}]
|
||||
let s:cfg2 = nuwiki#lsp#settings()
|
||||
call s:check('native g:nuwiki_wikis wins',
|
||||
\ len(get(s:cfg2, 'wikis', [])) == 1
|
||||
\ && get(s:cfg2.wikis[0], 'root', '') ==# '~/native')
|
||||
unlet g:nuwiki_wikis
|
||||
|
||||
" ----- nuwiki-native scalar overrides the vimwiki global -----
|
||||
let g:vimwiki_list = []
|
||||
let g:nuwiki_toc_header_level = 4
|
||||
let s:cfg3 = nuwiki#lsp#settings()
|
||||
" No wikis list now (vimwiki_list empty) → scalar folded into top-level.
|
||||
call s:check('native scalar override', get(s:cfg3, 'toc_header_level', 0) == 4)
|
||||
unlet g:nuwiki_toc_header_level
|
||||
|
||||
" ----- global shorthand on native g:nuwiki_wikis (vimwiki-style) -----
|
||||
" A global default applies to every wiki; a per-wiki value overrides it.
|
||||
let g:nuwiki_toc_header_level = 2
|
||||
let g:nuwiki_html_header_numbering = 2
|
||||
let g:nuwiki_wikis = [
|
||||
\ {'root': '~/w1', 'name': 'One'},
|
||||
\ {'root': '~/w2', 'name': 'Two', 'toc_header_level': 3},
|
||||
\ ]
|
||||
let s:cfg4 = nuwiki#lsp#settings()
|
||||
let s:g0 = s:cfg4.wikis[0]
|
||||
let s:g1 = s:cfg4.wikis[1]
|
||||
call s:check('global folds into wiki 0', get(s:g0, 'toc_header_level', 0) == 2)
|
||||
call s:check('global numbering folds', get(s:g0, 'html_header_numbering', 0) == 2)
|
||||
call s:check('per-wiki override wins', get(s:g1, 'toc_header_level', 0) == 3)
|
||||
call s:check('global still folds elsewhere', get(s:g1, 'html_header_numbering', 0) == 2)
|
||||
" The user's original dict must not be mutated by the fold.
|
||||
call s:check('no mutation of g:nuwiki_wikis', !has_key(g:nuwiki_wikis[0], 'toc_header_level'))
|
||||
|
||||
call writefile(s:out, $NUWIKI_VWC_OUT)
|
||||
+10
-4
@@ -525,11 +525,15 @@ Page generation ~
|
||||
|
||||
*:NuwikiTOC*
|
||||
:NuwikiTOC
|
||||
Generate or refresh the table of contents on the current page.
|
||||
Generate or refresh the table of contents on the current page. A new TOC
|
||||
is inserted at the cursor line; an existing one is refreshed in place.
|
||||
|
||||
*:NuwikiGenerateLinks*
|
||||
:NuwikiGenerateLinks
|
||||
Insert a flat list of every page in the wiki under the cursor.
|
||||
:NuwikiGenerateLinks [path]
|
||||
Insert a flat list of every page in the wiki. With a `path` argument, the
|
||||
list is scoped to that subtree (a wiki-root-relative directory, or a glob
|
||||
pattern containing `*`/`?`). A new section is inserted at the cursor line;
|
||||
an existing one is refreshed in place.
|
||||
|
||||
*:NuwikiCheckLinks*
|
||||
:[range]NuwikiCheckLinks
|
||||
@@ -555,7 +559,9 @@ Tags ~
|
||||
*:NuwikiGenerateTagLinks*
|
||||
:NuwikiGenerateTagLinks [tag]
|
||||
Insert a section linking every page that has `<tag>`. With no
|
||||
argument, generates a section per tag found in the workspace.
|
||||
argument, generates a section per tag found in the workspace. A new
|
||||
section is inserted at the cursor line; an existing one is refreshed in
|
||||
place.
|
||||
|
||||
*:NuwikiRebuildTags*
|
||||
:NuwikiRebuildTags[!]
|
||||
|
||||
@@ -128,6 +128,16 @@ correct as-is; the deferral is a deliberate risk/reward call.
|
||||
|
||||
## Notes
|
||||
|
||||
- **Upstream `g:vimwiki_list` config (Vim & Neovim).** When you haven't
|
||||
configured nuwiki natively, both clients read your upstream `g:vimwiki_list` +
|
||||
`g:vimwiki_*` globals and translate them into nuwiki's schema (per-wiki
|
||||
`path`/`path_html`/`template_*`/`auto_export`, plus globals `toc_header`,
|
||||
`toc_header_level`, `html_header_numbering`, `html_header_numbering_sym`,
|
||||
`links_space_char`, `list_margin`). `g:nuwiki_*` / `setup()` config always
|
||||
wins. Globals nuwiki doesn't model (e.g. `automatic_nested_syntaxes`) are
|
||||
ignored — see the divergence list above. On Neovim this means swapping the
|
||||
`vimwiki` plugin for nuwiki under lazy.nvim works with your existing
|
||||
`vim.g.vimwiki_list` untouched (call `require('nuwiki').setup()` with no args).
|
||||
- **Third-party plugin compatibility.** A shim at `autoload/vimwiki/vars.vim`
|
||||
exposes the subset of `vimwiki#vars#get_wikilocal` that plugins like
|
||||
vimwiki-sync and vim-zettel call (`path`, `ext`/`extension`, `syntax`,
|
||||
|
||||
+11
-6
@@ -513,16 +513,20 @@ end
|
||||
|
||||
-- ===== Generation + workspace =====
|
||||
|
||||
M.toc_generate = function() exec('nuwiki.toc.generate', uri_args()) end
|
||||
M.toc_generate = function()
|
||||
-- Send the cursor line (0-based) so a fresh TOC is inserted there.
|
||||
local line = vim.api.nvim_win_get_cursor(0)[1] - 1
|
||||
exec('nuwiki.toc.generate', { { uri = buf_uri(), line = line } })
|
||||
end
|
||||
-- `:VimwikiGenerateLinks [rel_path]` — optional path scopes the links to a
|
||||
-- subtree via the server's generateForPath; no arg = every page.
|
||||
M.links_generate = function(path)
|
||||
-- Cursor line (0-based) so a fresh section is inserted there.
|
||||
local line = vim.api.nvim_win_get_cursor(0)[1] - 1
|
||||
if path and path ~= '' then
|
||||
local args = uri_args()[1]
|
||||
args.path = path
|
||||
exec('nuwiki.links.generateForPath', { args })
|
||||
exec('nuwiki.links.generateForPath', { { uri = buf_uri(), path = path, line = line } })
|
||||
else
|
||||
exec('nuwiki.links.generate', uri_args())
|
||||
exec('nuwiki.links.generate', { { uri = buf_uri(), line = line } })
|
||||
end
|
||||
end
|
||||
|
||||
@@ -602,7 +606,8 @@ function M.tags_search(query)
|
||||
end
|
||||
|
||||
function M.tags_generate_links(tag)
|
||||
local args = { uri = buf_uri() }
|
||||
-- Cursor line (0-based) so a fresh section is inserted there.
|
||||
local args = { uri = buf_uri(), line = vim.api.nvim_win_get_cursor(0)[1] - 1 }
|
||||
if tag and tag ~= '' then
|
||||
args.tag = tag
|
||||
end
|
||||
|
||||
+126
-10
@@ -35,10 +35,19 @@ M.defaults = {
|
||||
-- custom_wiki2html, custom_wiki2html_args, base_url,
|
||||
-- toc_header, toc_header_level, links_header, links_header_level,
|
||||
-- tags_header, tags_header_level,
|
||||
-- html_header_numbering, html_header_numbering_sym,
|
||||
-- listsyms, listsym_rejected, listsyms_propagate, list_margin,
|
||||
-- links_space_char
|
||||
wikis = nil,
|
||||
|
||||
-- TOC defaults (per-wiki, also accepted as top-level for single-wiki setups)
|
||||
toc_header = 'Contents',
|
||||
toc_header_level = 1,
|
||||
|
||||
-- HTML export defaults (per-wiki, also accepted as top-level for single-wiki setups)
|
||||
html_header_numbering = 0,
|
||||
html_header_numbering_sym = '',
|
||||
|
||||
-- Per-buffer glue. Each subgroup mirrors vimwiki's
|
||||
-- `g:vimwiki_key_mappings` shape and can be flipped off
|
||||
-- independently to suppress that group of keymaps.
|
||||
@@ -94,19 +103,126 @@ M.defaults = {
|
||||
|
||||
M.options = vim.deepcopy(M.defaults)
|
||||
|
||||
-- The raw table the user passed to setup(), kept separate from the
|
||||
-- defaults-merged `options`. Used to tell an explicitly-set value apart from
|
||||
-- a built-in default — e.g. so the LSP client only treats a per-wiki key as a
|
||||
-- global default when the user actually set it.
|
||||
M.user = {}
|
||||
|
||||
function M.apply(user)
|
||||
M.user = user or {}
|
||||
M.options = vim.tbl_deep_extend('force', M.defaults, user or {})
|
||||
end
|
||||
|
||||
-- ===== Upstream vimwiki config compatibility (drop-in) =====
|
||||
--
|
||||
-- Single source of truth for the configured wikis, shared by the LSP payload
|
||||
-- (lsp.lua) and the buffer-side commands. Mirrors autoload/nuwiki/config.vim so
|
||||
-- a user who keeps their original vim.g.vimwiki_list + g:vimwiki_* config (e.g.
|
||||
-- swapping the vimwiki plugin for nuwiki under lazy.nvim) needs no rewrite.
|
||||
-- Native config (setup() wikis / g:nuwiki_*) always wins.
|
||||
|
||||
-- Upstream vimwiki per-wiki dict key -> nuwiki/server key.
|
||||
local VIMWIKI_WIKI_MAP = {
|
||||
path = 'root',
|
||||
path_html = 'html_path',
|
||||
template_path = 'template_path',
|
||||
template_default = 'template_default',
|
||||
template_ext = 'template_ext',
|
||||
css_name = 'css_name',
|
||||
auto_export = 'auto_export',
|
||||
auto_toc = 'auto_toc',
|
||||
syntax = 'syntax',
|
||||
ext = 'file_extension',
|
||||
index = 'index',
|
||||
diary_rel_path = 'diary_rel_path',
|
||||
diary_index = 'diary_index',
|
||||
name = 'name',
|
||||
}
|
||||
|
||||
-- Per-wiki settings that double as wiki-wide global defaults.
|
||||
local SCALAR_GLOBAL_KEYS = {
|
||||
'toc_header', 'toc_header_level', 'toc_link_format',
|
||||
'links_header', 'links_header_level',
|
||||
'tags_header', 'tags_header_level',
|
||||
'html_header_numbering', 'html_header_numbering_sym',
|
||||
'links_space_char', 'list_margin',
|
||||
'listsyms', 'listsym_rejected',
|
||||
'auto_export', 'auto_toc',
|
||||
'auto_generate_links', 'auto_generate_tags', 'auto_diary_index',
|
||||
}
|
||||
|
||||
-- Resolve the global scalar defaults applied to every wiki. Priority (low to
|
||||
-- high): g:vimwiki_<key>, g:nuwiki_<key>, explicit setup() value. Built-in
|
||||
-- defaults are excluded (reads M.user, not M.options) so they don't get folded
|
||||
-- onto every wiki and override per-wiki values.
|
||||
function M.scalar_globals()
|
||||
local g = {}
|
||||
for _, key in ipairs(SCALAR_GLOBAL_KEYS) do
|
||||
if vim.g['vimwiki_' .. key] ~= nil then
|
||||
g[key] = vim.g['vimwiki_' .. key]
|
||||
end
|
||||
if vim.g['nuwiki_' .. key] ~= nil then
|
||||
g[key] = vim.g['nuwiki_' .. key]
|
||||
end
|
||||
if M.user[key] ~= nil then
|
||||
g[key] = M.user[key]
|
||||
end
|
||||
end
|
||||
return g
|
||||
end
|
||||
|
||||
-- Translate vim.g.vimwiki_list into nuwiki's wiki list (server key names),
|
||||
-- folding the global scalars into each entry. Returns {} when unset/malformed.
|
||||
local function wikis_from_vimwiki(globals)
|
||||
local list = vim.g.vimwiki_list
|
||||
if type(list) ~= 'table' or vim.tbl_isempty(list) then
|
||||
return {}
|
||||
end
|
||||
local out = {}
|
||||
for _, vw in ipairs(list) do
|
||||
if type(vw) == 'table' then
|
||||
local w = vim.tbl_extend('force', {}, globals)
|
||||
for src, dst in pairs(VIMWIKI_WIKI_MAP) do
|
||||
if vw[src] ~= nil then
|
||||
w[dst] = vw[src]
|
||||
end
|
||||
end
|
||||
if w.root ~= nil then
|
||||
table.insert(out, w)
|
||||
end
|
||||
end
|
||||
end
|
||||
return out
|
||||
end
|
||||
|
||||
-- The configured wikis as normalized dicts (nuwiki/server key names), with the
|
||||
-- global scalars folded in (per-wiki value wins). Priority:
|
||||
-- 1. setup() opts.wikis
|
||||
-- 2. g:nuwiki_wikis
|
||||
-- 3. g:vimwiki_list (upstream drop-in)
|
||||
-- Returns {} when none is set — callers fall back to the single-wiki shorthand.
|
||||
function M.wikis()
|
||||
local globals = M.scalar_globals()
|
||||
local native = M.options.wikis or vim.g.nuwiki_wikis
|
||||
if type(native) == 'table' and #native > 0 then
|
||||
local out = {}
|
||||
for i, w in ipairs(native) do
|
||||
out[i] = vim.tbl_extend('force', globals, w)
|
||||
end
|
||||
return out
|
||||
end
|
||||
return wikis_from_vimwiki(globals)
|
||||
end
|
||||
|
||||
-- Resolve the config for wiki #n (0-based) without touching the LSP.
|
||||
-- Priority: setup() opts.wikis → g:nuwiki_wikis (VimL) → scalar opts/g: vars.
|
||||
-- Returns { name, root, ext, idx, diary_rel, diary_idx }.
|
||||
-- Returns { name, root, ext, idx, diary_rel, diary_idx, space }.
|
||||
function M.wiki_cfg(n)
|
||||
local opts = M.options
|
||||
local wikis = opts.wikis or vim.g.nuwiki_wikis or nil
|
||||
local w = wikis and wikis[(n or 0) + 1] or nil
|
||||
local root = (w and w.root) or opts.wiki_root or vim.g.nuwiki_wiki_root or '~/vimwiki'
|
||||
local ext = (w and w.file_extension) or opts.file_extension or vim.g.nuwiki_file_extension or '.wiki'
|
||||
local wikis = M.wikis()
|
||||
local w = wikis[(n or 0) + 1]
|
||||
local root = (w and w.root) or M.options.wiki_root or vim.g.nuwiki_wiki_root or '~/vimwiki'
|
||||
local ext = (w and w.file_extension) or M.options.file_extension
|
||||
or vim.g.nuwiki_file_extension or '.wiki'
|
||||
if not ext:match('^%.') then ext = '.' .. ext end
|
||||
return {
|
||||
name = (w and w.name) or vim.fn.fnamemodify(vim.fn.expand(root), ':t'),
|
||||
@@ -115,7 +231,7 @@ 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
|
||||
space = (w and w.links_space_char) or M.options.links_space_char
|
||||
or vim.g.nuwiki_links_space_char or ' ',
|
||||
}
|
||||
end
|
||||
@@ -123,9 +239,9 @@ end
|
||||
-- The full list of configured wikis as wiki_cfg dicts. Falls back to a single
|
||||
-- entry built from the scalar wiki_root config when no list is configured.
|
||||
function M.wiki_list()
|
||||
local wikis = M.options.wikis or vim.g.nuwiki_wikis or nil
|
||||
local wikis = M.wikis()
|
||||
local list = {}
|
||||
if type(wikis) == 'table' and #wikis > 0 then
|
||||
if #wikis > 0 then
|
||||
for i = 1, #wikis do
|
||||
list[i] = M.wiki_cfg(i - 1)
|
||||
end
|
||||
|
||||
@@ -93,6 +93,17 @@ end
|
||||
function M.setup(opts)
|
||||
config.apply(opts or {})
|
||||
|
||||
-- Bridge a Lua-only `setup({ wikis = … })` config to VimL. The buffer-side
|
||||
-- VimL helpers (autoload/nuwiki/config.vim) and the vimwiki compat shim
|
||||
-- (autoload/vimwiki/vars.vim — used by third-party plugins such as
|
||||
-- vimwiki-sync) read `g:nuwiki_wikis`, which can't see a config that only
|
||||
-- lives in Lua. Publishing the resolved list keeps both worlds in sync. (A
|
||||
-- `g:nuwiki_wikis` / `g:vimwiki_list` config is already VimL-visible, so this
|
||||
-- only fires for the pure-`setup()` case.)
|
||||
if type(config.options.wikis) == 'table' and #config.options.wikis > 0 then
|
||||
vim.g.nuwiki_wikis = config.wikis()
|
||||
end
|
||||
|
||||
-- Neovim 0.7+ resolves filetypes via `vim.filetype.match` before any
|
||||
-- `ftdetect/*.vim` autocmd fires, and its bundled rule for `*.wiki`
|
||||
-- returns `mediawiki`. Register an authoritative rule here so this
|
||||
|
||||
@@ -74,8 +74,11 @@ local function download_release(dest)
|
||||
if vim.fn.executable('curl') ~= 1 or vim.fn.executable('tar') ~= 1 then
|
||||
return false
|
||||
end
|
||||
-- Gitea serves the most recent release under the `latest` tag segment
|
||||
-- (`/releases/download/latest/<asset>`). Note this is NOT GitHub's
|
||||
-- `/releases/latest/download/<asset>` shape, which Gitea 404s.
|
||||
local url = string.format(
|
||||
'https://code.gfran.co/gffranco/nuwiki/releases/latest/download/nuwiki-ls-%s.tar.gz',
|
||||
'https://code.gfran.co/gffranco/nuwiki/releases/download/latest/nuwiki-ls-%s.tar.gz',
|
||||
target
|
||||
)
|
||||
local archive = vim.fn.tempname() .. '.tar.gz'
|
||||
|
||||
+14
-5
@@ -14,13 +14,22 @@ local function command()
|
||||
return { install.expected_path() }
|
||||
end
|
||||
|
||||
-- Merge g:nuwiki_wikis (VimL config) into opts.wikis when the user has not
|
||||
-- passed wikis through setup(). This lets users who configure via a .vim
|
||||
-- file avoid duplicating the list in their Lua setup() call.
|
||||
-- Build the server config payload. The wiki list comes from the shared
|
||||
-- resolver (config.wikis) — native setup() wikis, g:nuwiki_wikis, or an
|
||||
-- upstream g:vimwiki_list, with global scalar defaults already folded — so the
|
||||
-- payload and the buffer-side commands agree on which wikis exist.
|
||||
local function resolved_opts()
|
||||
local opts = config.options
|
||||
if not opts.wikis and vim.g.nuwiki_wikis then
|
||||
opts = vim.tbl_extend('force', opts, { wikis = vim.g.nuwiki_wikis })
|
||||
local wikis = config.wikis()
|
||||
if #wikis > 0 then
|
||||
opts = vim.tbl_extend('force', opts, { wikis = wikis })
|
||||
else
|
||||
-- Single-wiki shorthand: fold the global scalars into the top-level payload
|
||||
-- so the server's single-wiki desugaring honours them.
|
||||
local globals = config.scalar_globals()
|
||||
if not vim.tbl_isempty(globals) then
|
||||
opts = vim.tbl_extend('keep', opts, globals)
|
||||
end
|
||||
end
|
||||
return opts
|
||||
end
|
||||
|
||||
+6
-1
@@ -15,7 +15,7 @@ let g:loaded_nuwiki = 1
|
||||
" Plugin version, surfaced by :VimwikiShowVersion / :NuwikiShowVersion.
|
||||
" Set before the Neovim early-return below so both clients see the global.
|
||||
if !exists('g:nuwiki_version')
|
||||
let g:nuwiki_version = '0.1.0'
|
||||
let g:nuwiki_version = '0.4.2'
|
||||
endif
|
||||
|
||||
" Resolve the plugin root NOW, while this script is being sourced and
|
||||
@@ -37,6 +37,11 @@ endif
|
||||
augroup nuwiki_calendar_detect
|
||||
autocmd!
|
||||
autocmd FileType vimwiki call s:nuwiki_setup_calendar()
|
||||
" Track the wiki of the active buffer so the calendar diaries into the wiki
|
||||
" you're editing (not always the first one). FileType covers initial load;
|
||||
" BufEnter covers switching back to an already-open wiki buffer.
|
||||
autocmd FileType vimwiki call nuwiki#diary#track_wiki(expand('%:p'))
|
||||
autocmd BufEnter * if &filetype ==# 'vimwiki' | call nuwiki#diary#track_wiki(expand('%:p')) | endif
|
||||
augroup END
|
||||
function! s:nuwiki_setup_calendar() abort
|
||||
if !get(g:, 'nuwiki_use_calendar', 1) || get(g:, 'nuwiki_no_calendar', 0)
|
||||
|
||||
@@ -3,6 +3,14 @@
|
||||
"
|
||||
" Invoked by Dein / vim-plug build hooks:
|
||||
" vim -e -s -c "source scripts/download_bin.vim" -c "q"
|
||||
"
|
||||
" That invocation runs in 'compatible' mode, where leading-backslash line
|
||||
" continuations aren't recognised (they'd raise E10, and any error in silent
|
||||
" Ex mode makes Vim exit non-zero — a spurious build failure). Reset
|
||||
" 'cpoptions' to the Vim default for the duration so the script parses, and
|
||||
" restore it at the end.
|
||||
let s:cpo_save = &cpo
|
||||
set cpo&vim
|
||||
|
||||
let s:plugin_dir = fnamemodify(resolve(expand('<sfile>:p')), ':h:h')
|
||||
let s:bin_dir = s:plugin_dir . '/bin'
|
||||
@@ -56,7 +64,10 @@ function! s:download_release() abort
|
||||
if !executable('curl') || !executable('tar')
|
||||
return 0
|
||||
endif
|
||||
let l:url = 'https://code.gfran.co/gffranco/nuwiki/releases/latest/download/nuwiki-ls-'
|
||||
" Gitea serves the most recent release under the `latest` tag segment
|
||||
" (/releases/download/latest/<asset>). NOT GitHub's
|
||||
" /releases/latest/download/<asset> shape, which Gitea 404s.
|
||||
let l:url = 'https://code.gfran.co/gffranco/nuwiki/releases/download/latest/nuwiki-ls-'
|
||||
\ . l:target . '.tar.gz'
|
||||
let l:archive = tempname() . '.tar.gz'
|
||||
call system('curl -fsSL -o ' . shellescape(l:archive) . ' ' . shellescape(l:url))
|
||||
@@ -86,3 +97,5 @@ else
|
||||
echohl ErrorMsg | echom 'nuwiki: install failed' | echohl None
|
||||
endif
|
||||
endif
|
||||
|
||||
let &cpo = s:cpo_save
|
||||
|
||||
Executable
+57
@@ -0,0 +1,57 @@
|
||||
#!/usr/bin/env bash
|
||||
# scripts/set-version.sh — stamp a release version into every hardcoded spot.
|
||||
#
|
||||
# This is the single source of truth for *where* the version lives. The
|
||||
# release pipeline (.gitea/workflows/release.yaml) calls it with the
|
||||
# workflow-dispatch input; run it the same way to prepare a release by hand:
|
||||
#
|
||||
# scripts/set-version.sh 0.4.0
|
||||
#
|
||||
# It rewrites:
|
||||
# 1. Cargo.toml — [workspace.package] version
|
||||
# 2. crates/nuwiki-lsp/Cargo.toml — nuwiki-core path-dep version pin
|
||||
# 3. crates/nuwiki-ls/Cargo.toml — nuwiki-lsp path-dep version pin
|
||||
# 4. plugin/nuwiki.vim — g:nuwiki_version (the Lua client reads it too)
|
||||
# 5. Cargo.lock — the version of our three own crates
|
||||
#
|
||||
# Cargo.lock is patched directly (awk) so this needs no cargo/toolchain —
|
||||
# the only thing that changes for our crates is their version string.
|
||||
set -euo pipefail
|
||||
|
||||
ver="${1:-}"
|
||||
ver="${ver#v}" # tolerate a leading "v"
|
||||
if ! printf '%s' "$ver" | grep -Eq '^[0-9]+\.[0-9]+\.[0-9]+([-.][0-9A-Za-z.-]+)?$'; then
|
||||
echo "usage: $0 <semver> e.g. $0 0.4.0" >&2
|
||||
exit 2
|
||||
fi
|
||||
|
||||
root="$(cd "$(dirname "$0")/.." && pwd)"
|
||||
cd "$root"
|
||||
|
||||
# 1. Workspace package version (root Cargo.toml, [workspace.package]).
|
||||
sed -i -E "s/^version = \"[0-9][^\"]*\"/version = \"$ver\"/" Cargo.toml
|
||||
|
||||
# 2-3. Internal path-dep pins must track the workspace version.
|
||||
sed -i -E "s|(nuwiki-core = \{ path = \"\.\./nuwiki-core\", version = )\"[^\"]*\"|\1\"$ver\"|" \
|
||||
crates/nuwiki-lsp/Cargo.toml
|
||||
sed -i -E "s|(nuwiki-lsp = \{ path = \"\.\./nuwiki-lsp\", version = )\"[^\"]*\"|\1\"$ver\"|" \
|
||||
crates/nuwiki-ls/Cargo.toml
|
||||
|
||||
# 4. VimL client version global (the Lua client reads this same g: var).
|
||||
sed -i -E "s/(let g:nuwiki_version = ')[^']*(')/\1$ver\2/" plugin/nuwiki.vim
|
||||
|
||||
# 5. Cargo.lock entries for our own crates. Each [[package]] block lists the
|
||||
# name line immediately followed by the version line; rewrite only those.
|
||||
for crate in nuwiki-core nuwiki-lsp nuwiki-ls; do
|
||||
awk -v c="$crate" -v v="$ver" '
|
||||
$0 == "name = \"" c "\"" {
|
||||
print
|
||||
if (getline > 0) { sub(/version = "[^"]*"/, "version = \"" v "\"") }
|
||||
print
|
||||
next
|
||||
}
|
||||
{ print }
|
||||
' Cargo.lock > Cargo.lock.tmp && mv Cargo.lock.tmp Cargo.lock
|
||||
done
|
||||
|
||||
echo "Set version to $ver"
|
||||
Reference in New Issue
Block a user