2026-05-11 00:27:06 +00:00
|
|
|
//! Workspace index — the in-memory model of every `.wiki` page nuwiki
|
|
|
|
|
//! has seen, used to power Phase 8 nav features.
|
|
|
|
|
//!
|
|
|
|
|
//! Per P8 (SPEC §11), the initial scan runs as a background tokio task so
|
|
|
|
|
//! the server stays responsive on startup. Per-document updates land here
|
|
|
|
|
//! synchronously via `upsert` whenever the LSP backend re-parses on
|
|
|
|
|
//! `didOpen` / `didChange`.
|
|
|
|
|
//!
|
|
|
|
|
//! The index keeps only the projections nav needs (headings + outgoing
|
|
|
|
|
//! links) — the full AST already lives in the backend's `documents`
|
|
|
|
|
//! `DashMap`.
|
|
|
|
|
|
|
|
|
|
use std::collections::HashMap;
|
|
|
|
|
use std::path::{Path, PathBuf};
|
|
|
|
|
|
|
|
|
|
use nuwiki_core::ast::{
|
|
|
|
|
BlockNode, BlockquoteNode, DocumentNode, InlineNode, LinkKind, LinkTarget, ListNode, Span,
|
2026-05-11 14:54:55 +00:00
|
|
|
TableNode, TagScope,
|
2026-05-11 00:27:06 +00:00
|
|
|
};
|
2026-05-11 21:04:55 +00:00
|
|
|
use nuwiki_core::date::DiaryDate;
|
2026-05-11 00:27:06 +00:00
|
|
|
use tower_lsp::lsp_types::Url;
|
|
|
|
|
|
|
|
|
|
#[derive(Debug, Clone)]
|
|
|
|
|
pub struct IndexedPage {
|
|
|
|
|
pub uri: Url,
|
|
|
|
|
/// Page name relative to the workspace root, without the `.wiki`
|
|
|
|
|
/// extension. Falls back to the URL's last path segment if no root.
|
|
|
|
|
pub name: String,
|
|
|
|
|
pub title: Option<String>,
|
|
|
|
|
pub headings: Vec<HeadingInfo>,
|
|
|
|
|
pub outgoing: Vec<OutgoingLink>,
|
2026-05-11 14:54:55 +00:00
|
|
|
/// Phase 12: every `TagNode` on the page. `tags_by_name` on the
|
|
|
|
|
/// containing `WorkspaceIndex` is the reverse map.
|
|
|
|
|
pub tags: Vec<TagInfo>,
|
2026-05-12 22:09:14 +00:00
|
|
|
/// Phase 16: `Some(date)` when this page is a *daily* diary entry —
|
|
|
|
|
/// stem parses as `YYYY-MM-DD`. Equivalent to
|
|
|
|
|
/// `diary_period.and_then(|p| if let DiaryPeriod::Day(d) = p { Some(d) } else { None })`.
|
|
|
|
|
/// Kept as its own field for back-compat with prev/next/list callers
|
|
|
|
|
/// that pre-date the multi-frequency upgrade.
|
2026-05-11 21:04:55 +00:00
|
|
|
pub diary_date: Option<DiaryDate>,
|
2026-05-12 22:09:14 +00:00
|
|
|
/// Cluster 4 (vimwiki parity): `Some(period)` for any diary entry
|
|
|
|
|
/// (daily, weekly, monthly, yearly). The stem must match the
|
|
|
|
|
/// canonical format for one of the four frequencies.
|
|
|
|
|
pub diary_period: Option<nuwiki_core::date::DiaryPeriod>,
|
2026-05-11 14:54:55 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// One tag occurrence on a page. `name` is the bare tag string (no
|
|
|
|
|
/// surrounding colons). Multiple tags on the same source line each get
|
|
|
|
|
/// their own `TagInfo` so anchor lookup can return a precise location.
|
|
|
|
|
#[derive(Debug, Clone)]
|
|
|
|
|
pub struct TagInfo {
|
|
|
|
|
pub name: String,
|
|
|
|
|
pub scope: TagScope,
|
|
|
|
|
pub span: Span,
|
2026-05-11 00:27:06 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[derive(Debug, Clone)]
|
|
|
|
|
pub struct HeadingInfo {
|
|
|
|
|
pub title: String,
|
|
|
|
|
pub level: u8,
|
|
|
|
|
pub anchor: String,
|
|
|
|
|
pub span: Span,
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-26 22:29:30 -03:00
|
|
|
impl IndexedPage {
|
|
|
|
|
/// Find a heading whose anchor matches `request`. The on-disk wikilink
|
|
|
|
|
/// syntax lets users write either the slug (`[[Page#some-heading]]`) or
|
|
|
|
|
/// the raw heading text (`[[Page#Some Heading]]`). `slugify` is
|
|
|
|
|
/// idempotent on valid slug input, so applying it to the request
|
|
|
|
|
/// before comparison handles both forms in a single pass.
|
|
|
|
|
pub fn find_heading_by_anchor(&self, request: &str) -> Option<&HeadingInfo> {
|
|
|
|
|
let needle = slugify(request);
|
|
|
|
|
self.headings.iter().find(|h| h.anchor == needle)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-11 00:27:06 +00:00
|
|
|
#[derive(Debug, Clone)]
|
|
|
|
|
pub struct OutgoingLink {
|
|
|
|
|
/// Resolved target page name (for `LinkKind::Wiki`) or `None` for kinds
|
|
|
|
|
/// that don't address another page in the workspace (`AnchorOnly`,
|
|
|
|
|
/// `Raw`, `File`, `Local`, `Diary`, …).
|
|
|
|
|
pub target_page: Option<String>,
|
|
|
|
|
pub anchor: Option<String>,
|
|
|
|
|
pub kind: LinkKind,
|
|
|
|
|
pub span: Span,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[derive(Debug, Clone)]
|
|
|
|
|
pub struct Backlink {
|
|
|
|
|
pub source_uri: Url,
|
|
|
|
|
pub source_span: Span,
|
|
|
|
|
pub target_anchor: Option<String>,
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-11 14:54:55 +00:00
|
|
|
/// A tag's location, used by `WorkspaceIndex::tags_for` for cross-page
|
|
|
|
|
/// `[[Page#tag]]` resolution and `nuwiki.tags.search` (Phase 13).
|
|
|
|
|
#[derive(Debug, Clone)]
|
|
|
|
|
pub struct TagOccurrence {
|
|
|
|
|
pub uri: Url,
|
|
|
|
|
pub span: Span,
|
|
|
|
|
pub scope: TagScope,
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-11 00:27:06 +00:00
|
|
|
#[derive(Debug, Default)]
|
|
|
|
|
pub struct WorkspaceIndex {
|
|
|
|
|
pub root: Option<PathBuf>,
|
2026-05-11 21:04:55 +00:00
|
|
|
/// Phase 16: subdir relative to `root` that holds diary entries.
|
|
|
|
|
/// Mirrors `WikiConfig::diary_rel_path`; stored here so `upsert` can
|
|
|
|
|
/// classify each indexed URI without a back-reference to the config.
|
|
|
|
|
pub diary_rel_path: Option<String>,
|
2026-05-26 22:00:03 -03:00
|
|
|
/// File extension for this wiki's pages (e.g. `".wiki"` or `"wiki"`).
|
|
|
|
|
/// Used to normalise link targets that include the extension so that
|
|
|
|
|
/// `[[page.wiki]]` resolves the same way as `[[page]]`.
|
|
|
|
|
pub file_extension: Option<String>,
|
2026-05-11 00:27:06 +00:00
|
|
|
pub pages_by_uri: HashMap<Url, IndexedPage>,
|
|
|
|
|
pub pages_by_name: HashMap<String, Url>,
|
|
|
|
|
pub backlinks: HashMap<String, Vec<Backlink>>,
|
2026-05-11 14:54:55 +00:00
|
|
|
/// Phase 12: tag name → every occurrence across the workspace. Used by
|
|
|
|
|
/// the v1.1 nav handlers (tag-as-anchor `definition`, `workspace/symbol`
|
|
|
|
|
/// inclusion) and the Phase 13 `nuwiki.tags.search` command.
|
|
|
|
|
pub tags_by_name: HashMap<String, Vec<TagOccurrence>>,
|
2026-05-11 00:27:06 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
impl WorkspaceIndex {
|
|
|
|
|
pub fn new(root: Option<PathBuf>) -> Self {
|
|
|
|
|
Self {
|
|
|
|
|
root,
|
|
|
|
|
..Default::default()
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-11 21:04:55 +00:00
|
|
|
pub fn with_diary_rel_path(mut self, rel: Option<String>) -> Self {
|
|
|
|
|
self.diary_rel_path = rel;
|
|
|
|
|
self
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-26 22:00:03 -03:00
|
|
|
pub fn with_file_extension(mut self, ext: Option<String>) -> Self {
|
|
|
|
|
self.file_extension = ext;
|
|
|
|
|
self
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Strip the wiki's configured file extension from a link target path.
|
|
|
|
|
/// Handles both `"page.wiki"` → `"page"` and `"subdir/page.wiki"` →
|
|
|
|
|
/// `"subdir/page"`. Returns `path` unchanged when the extension doesn't
|
|
|
|
|
/// match or no extension is configured.
|
|
|
|
|
pub fn strip_link_extension<'a>(&self, path: &'a str) -> &'a str {
|
|
|
|
|
strip_wiki_extension(path, self.file_extension.as_deref())
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-11 00:27:06 +00:00
|
|
|
/// Insert or update an indexed page. Removes any prior indexing for
|
|
|
|
|
/// the same URI first (handles renames and re-parses without leaking
|
|
|
|
|
/// stale backlinks).
|
|
|
|
|
pub fn upsert(&mut self, uri: Url, ast: &DocumentNode) {
|
|
|
|
|
self.remove(&uri);
|
|
|
|
|
let name = page_name_from_uri(&uri, self.root.as_deref());
|
2026-05-12 22:09:14 +00:00
|
|
|
let diary_period =
|
|
|
|
|
diary_period_for_uri(&uri, self.root.as_deref(), self.diary_rel_path.as_deref());
|
|
|
|
|
let diary_date = diary_period.and_then(|p| match p {
|
|
|
|
|
nuwiki_core::date::DiaryPeriod::Day(d) => Some(d),
|
|
|
|
|
_ => None,
|
|
|
|
|
});
|
2026-05-11 00:27:06 +00:00
|
|
|
let mut page = IndexedPage {
|
|
|
|
|
uri: uri.clone(),
|
|
|
|
|
name: name.clone(),
|
|
|
|
|
title: ast.metadata.title.clone(),
|
|
|
|
|
headings: Vec::new(),
|
|
|
|
|
outgoing: Vec::new(),
|
2026-05-11 14:54:55 +00:00
|
|
|
tags: Vec::new(),
|
2026-05-11 21:04:55 +00:00
|
|
|
diary_date,
|
2026-05-12 22:09:14 +00:00
|
|
|
diary_period,
|
2026-05-11 00:27:06 +00:00
|
|
|
};
|
|
|
|
|
index_blocks(&ast.children, &mut page);
|
|
|
|
|
|
2026-05-26 22:00:03 -03:00
|
|
|
let ext = self.file_extension.as_deref();
|
2026-05-11 00:27:06 +00:00
|
|
|
for link in &page.outgoing {
|
|
|
|
|
if let Some(tgt) = &link.target_page {
|
2026-05-26 22:00:03 -03:00
|
|
|
let norm = strip_wiki_extension(tgt, ext);
|
2026-05-11 00:27:06 +00:00
|
|
|
self.backlinks
|
2026-05-26 22:00:03 -03:00
|
|
|
.entry(norm.to_string())
|
2026-05-11 00:27:06 +00:00
|
|
|
.or_default()
|
|
|
|
|
.push(Backlink {
|
|
|
|
|
source_uri: uri.clone(),
|
|
|
|
|
source_span: link.span,
|
|
|
|
|
target_anchor: link.anchor.clone(),
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-11 14:54:55 +00:00
|
|
|
// Phase 12: maintain the reverse tag map alongside the per-page list.
|
|
|
|
|
for tag in &page.tags {
|
|
|
|
|
self.tags_by_name
|
|
|
|
|
.entry(tag.name.clone())
|
|
|
|
|
.or_default()
|
|
|
|
|
.push(TagOccurrence {
|
|
|
|
|
uri: uri.clone(),
|
|
|
|
|
span: tag.span,
|
|
|
|
|
scope: tag.scope,
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-11 00:27:06 +00:00
|
|
|
self.pages_by_name.insert(name, uri.clone());
|
|
|
|
|
self.pages_by_uri.insert(uri, page);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
pub fn remove(&mut self, uri: &Url) {
|
|
|
|
|
if let Some(page) = self.pages_by_uri.remove(uri) {
|
|
|
|
|
self.pages_by_name.remove(&page.name);
|
|
|
|
|
}
|
|
|
|
|
for entries in self.backlinks.values_mut() {
|
|
|
|
|
entries.retain(|b| &b.source_uri != uri);
|
|
|
|
|
}
|
|
|
|
|
self.backlinks.retain(|_, v| !v.is_empty());
|
2026-05-11 14:54:55 +00:00
|
|
|
for entries in self.tags_by_name.values_mut() {
|
|
|
|
|
entries.retain(|t| &t.uri != uri);
|
|
|
|
|
}
|
|
|
|
|
self.tags_by_name.retain(|_, v| !v.is_empty());
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// All occurrences of a tag across the workspace. Empty slice when the
|
|
|
|
|
/// tag isn't indexed.
|
|
|
|
|
pub fn tags_for(&self, name: &str) -> &[TagOccurrence] {
|
|
|
|
|
self.tags_by_name
|
|
|
|
|
.get(name)
|
|
|
|
|
.map(Vec::as_slice)
|
|
|
|
|
.unwrap_or(&[])
|
2026-05-11 00:27:06 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Resolve a `LinkTarget` to a page URI in this workspace.
|
|
|
|
|
/// `source_page` is the name of the page the link originates from —
|
|
|
|
|
/// needed for `AnchorOnly` resolution.
|
|
|
|
|
pub fn resolve(&self, target: &LinkTarget, source_page: &str) -> Option<&Url> {
|
|
|
|
|
match target.kind {
|
2026-05-26 22:00:03 -03:00
|
|
|
LinkKind::Wiki => target.path.as_deref().and_then(|p| {
|
|
|
|
|
let normalized = self.strip_link_extension(p);
|
|
|
|
|
self.pages_by_name.get(normalized)
|
|
|
|
|
}),
|
2026-05-11 00:27:06 +00:00
|
|
|
LinkKind::AnchorOnly => self.pages_by_name.get(source_page),
|
|
|
|
|
// Interwiki / Diary / File / Local / Raw aren't resolved
|
|
|
|
|
// against the workspace index here. The handlers fall back to
|
|
|
|
|
// best-effort resolution (e.g. building file:// URLs).
|
|
|
|
|
_ => None,
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
pub fn page(&self, uri: &Url) -> Option<&IndexedPage> {
|
|
|
|
|
self.pages_by_uri.get(uri)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
pub fn page_by_name(&self, name: &str) -> Option<&IndexedPage> {
|
|
|
|
|
self.pages_by_name
|
|
|
|
|
.get(name)
|
|
|
|
|
.and_then(|u| self.pages_by_uri.get(u))
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
pub fn page_names(&self) -> Vec<String> {
|
|
|
|
|
let mut v: Vec<String> = self.pages_by_name.keys().cloned().collect();
|
|
|
|
|
v.sort();
|
|
|
|
|
v
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
pub fn backlinks_for(&self, page_name: &str) -> &[Backlink] {
|
|
|
|
|
self.backlinks
|
|
|
|
|
.get(page_name)
|
|
|
|
|
.map(Vec::as_slice)
|
|
|
|
|
.unwrap_or(&[])
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-12 22:09:14 +00:00
|
|
|
/// Return a parsed [`DiaryDate`] when `uri` is a daily diary entry —
|
|
|
|
|
/// the path lives under `<root>/<diary_rel_path>/` and its stem parses
|
|
|
|
|
/// as `YYYY-MM-DD`. For weekly/monthly/yearly entries see
|
|
|
|
|
/// [`diary_period_for_uri`].
|
2026-05-11 21:04:55 +00:00
|
|
|
pub fn diary_date_for_uri(
|
|
|
|
|
uri: &Url,
|
|
|
|
|
root: Option<&Path>,
|
|
|
|
|
diary_rel_path: Option<&str>,
|
|
|
|
|
) -> Option<DiaryDate> {
|
2026-05-12 22:09:14 +00:00
|
|
|
match diary_period_for_uri(uri, root, diary_rel_path)? {
|
|
|
|
|
nuwiki_core::date::DiaryPeriod::Day(d) => Some(d),
|
|
|
|
|
_ => None,
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Return a parsed [`DiaryPeriod`] when `uri` is a diary entry at any of
|
|
|
|
|
/// the four supported frequencies. Recognises:
|
|
|
|
|
///
|
|
|
|
|
/// `YYYY-MM-DD` → Day
|
|
|
|
|
/// `YYYY-Www` → Week (ISO)
|
|
|
|
|
/// `YYYY-MM` → Month
|
|
|
|
|
/// `YYYY` → Year
|
|
|
|
|
///
|
|
|
|
|
/// As with `diary_date_for_uri`, the path must sit under
|
|
|
|
|
/// `<root>/<diary_rel_path>/` to be accepted; without a `root` we accept
|
|
|
|
|
/// any stem (so ad-hoc test setups still work).
|
|
|
|
|
pub fn diary_period_for_uri(
|
|
|
|
|
uri: &Url,
|
|
|
|
|
root: Option<&Path>,
|
|
|
|
|
diary_rel_path: Option<&str>,
|
|
|
|
|
) -> Option<nuwiki_core::date::DiaryPeriod> {
|
2026-05-11 21:04:55 +00:00
|
|
|
let path = uri.to_file_path().ok()?;
|
|
|
|
|
let stem = path.file_stem()?.to_str()?;
|
2026-05-12 22:09:14 +00:00
|
|
|
let parsed = nuwiki_core::date::DiaryPeriod::parse(stem)?;
|
2026-05-11 21:04:55 +00:00
|
|
|
let Some(root) = root else {
|
|
|
|
|
return Some(parsed);
|
|
|
|
|
};
|
|
|
|
|
let parent = path.parent()?;
|
|
|
|
|
let expected = root.join(diary_rel_path.unwrap_or("diary"));
|
|
|
|
|
if parent.starts_with(&expected) {
|
|
|
|
|
Some(parsed)
|
|
|
|
|
} else {
|
|
|
|
|
None
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-11 00:27:06 +00:00
|
|
|
/// Derive a page name from a `file://` URL. With a workspace root the name
|
|
|
|
|
/// is the URL's path relative to root, sans `.wiki`. Without a root it's
|
|
|
|
|
/// just the filename stem.
|
|
|
|
|
pub fn page_name_from_uri(uri: &Url, root: Option<&Path>) -> String {
|
|
|
|
|
if let (Some(root), Ok(p)) = (root, uri.to_file_path()) {
|
|
|
|
|
if let Ok(rel) = p.strip_prefix(root) {
|
|
|
|
|
let stripped = rel.with_extension("");
|
|
|
|
|
return stripped
|
|
|
|
|
.to_string_lossy()
|
|
|
|
|
.replace(std::path::MAIN_SEPARATOR, "/");
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
// Fallback: last path segment, strip .wiki if present.
|
|
|
|
|
if let Ok(p) = uri.to_file_path() {
|
|
|
|
|
return p
|
|
|
|
|
.file_stem()
|
|
|
|
|
.map(|s| s.to_string_lossy().into_owned())
|
|
|
|
|
.unwrap_or_default();
|
|
|
|
|
}
|
|
|
|
|
uri.path_segments()
|
|
|
|
|
.and_then(|mut s| s.next_back())
|
|
|
|
|
.map(|s| {
|
|
|
|
|
s.strip_suffix(".wiki")
|
|
|
|
|
.unwrap_or(s)
|
|
|
|
|
.trim_start_matches('/')
|
|
|
|
|
.to_string()
|
|
|
|
|
})
|
|
|
|
|
.unwrap_or_default()
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Normalise heading text into a URL-safe anchor slug.
|
|
|
|
|
pub fn slugify(s: &str) -> String {
|
|
|
|
|
let mut out = String::with_capacity(s.len());
|
|
|
|
|
let mut prev_dash = false;
|
|
|
|
|
for ch in s.chars() {
|
|
|
|
|
if ch.is_alphanumeric() {
|
|
|
|
|
for c in ch.to_lowercase() {
|
|
|
|
|
out.push(c);
|
|
|
|
|
}
|
|
|
|
|
prev_dash = false;
|
|
|
|
|
} else if (ch.is_whitespace() || ch == '-' || ch == '_') && !prev_dash && !out.is_empty() {
|
|
|
|
|
out.push('-');
|
|
|
|
|
prev_dash = true;
|
|
|
|
|
}
|
|
|
|
|
// other punctuation: drop
|
|
|
|
|
}
|
|
|
|
|
while out.ends_with('-') {
|
|
|
|
|
out.pop();
|
|
|
|
|
}
|
|
|
|
|
out
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ===== Indexing helpers =====
|
|
|
|
|
|
|
|
|
|
fn index_blocks(blocks: &[BlockNode], page: &mut IndexedPage) {
|
|
|
|
|
for block in blocks {
|
|
|
|
|
index_block(block, page);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn index_block(block: &BlockNode, page: &mut IndexedPage) {
|
|
|
|
|
match block {
|
|
|
|
|
BlockNode::Heading(h) => {
|
|
|
|
|
let title = inline_to_text(&h.children);
|
|
|
|
|
page.headings.push(HeadingInfo {
|
|
|
|
|
title: title.clone(),
|
|
|
|
|
level: h.level,
|
|
|
|
|
anchor: slugify(&title),
|
|
|
|
|
span: h.span,
|
|
|
|
|
});
|
|
|
|
|
index_inlines(&h.children, page);
|
|
|
|
|
}
|
|
|
|
|
BlockNode::Paragraph(p) => index_inlines(&p.children, page),
|
|
|
|
|
BlockNode::Blockquote(BlockquoteNode { children, .. }) => {
|
|
|
|
|
for c in children {
|
|
|
|
|
index_block(c, page);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
BlockNode::List(ListNode { items, .. }) => {
|
|
|
|
|
for item in items {
|
|
|
|
|
index_inlines(&item.children, page);
|
|
|
|
|
if let Some(sub) = &item.sublist {
|
|
|
|
|
index_block(&BlockNode::List(sub.clone()), page);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
BlockNode::DefinitionList(dl) => {
|
|
|
|
|
for item in &dl.items {
|
|
|
|
|
if let Some(term) = &item.term {
|
|
|
|
|
index_inlines(term, page);
|
|
|
|
|
}
|
|
|
|
|
for def in &item.definitions {
|
|
|
|
|
index_inlines(def, page);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
BlockNode::Table(TableNode { rows, .. }) => {
|
|
|
|
|
for row in rows {
|
|
|
|
|
for cell in &row.cells {
|
|
|
|
|
index_inlines(&cell.children, page);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-05-11 14:54:55 +00:00
|
|
|
BlockNode::Tag(t) => {
|
|
|
|
|
for name in &t.tags {
|
|
|
|
|
page.tags.push(TagInfo {
|
|
|
|
|
name: name.clone(),
|
|
|
|
|
scope: t.scope,
|
|
|
|
|
span: t.span,
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-05-11 00:27:06 +00:00
|
|
|
_ => {}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn index_inlines(inlines: &[InlineNode], page: &mut IndexedPage) {
|
|
|
|
|
for n in inlines {
|
|
|
|
|
match n {
|
|
|
|
|
InlineNode::WikiLink(w) => {
|
|
|
|
|
page.outgoing.push(OutgoingLink {
|
|
|
|
|
target_page: w.target.path.clone(),
|
|
|
|
|
anchor: w.target.anchor.clone(),
|
|
|
|
|
kind: w.target.kind,
|
|
|
|
|
span: w.span,
|
|
|
|
|
});
|
|
|
|
|
if let Some(desc) = &w.description {
|
|
|
|
|
index_inlines(desc, page);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
InlineNode::ExternalLink(e) => {
|
|
|
|
|
if let Some(desc) = &e.description {
|
|
|
|
|
index_inlines(desc, page);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
InlineNode::Bold(b) => index_inlines(&b.children, page),
|
|
|
|
|
InlineNode::Italic(i) => index_inlines(&i.children, page),
|
|
|
|
|
InlineNode::BoldItalic(bi) => index_inlines(&bi.children, page),
|
|
|
|
|
InlineNode::Strikethrough(s) => index_inlines(&s.children, page),
|
|
|
|
|
InlineNode::Superscript(s) => index_inlines(&s.children, page),
|
|
|
|
|
InlineNode::Subscript(s) => index_inlines(&s.children, page),
|
|
|
|
|
InlineNode::Color(c) => index_inlines(&c.children, page),
|
|
|
|
|
_ => {}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn inline_to_text(inlines: &[InlineNode]) -> String {
|
|
|
|
|
let mut out = String::new();
|
|
|
|
|
inline_to_text_into(inlines, &mut out);
|
|
|
|
|
out.trim().to_string()
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn inline_to_text_into(inlines: &[InlineNode], out: &mut String) {
|
|
|
|
|
for n in inlines {
|
|
|
|
|
match n {
|
|
|
|
|
InlineNode::Text(t) => out.push_str(&t.content),
|
|
|
|
|
InlineNode::Bold(b) => inline_to_text_into(&b.children, out),
|
|
|
|
|
InlineNode::Italic(i) => inline_to_text_into(&i.children, out),
|
|
|
|
|
InlineNode::BoldItalic(bi) => inline_to_text_into(&bi.children, out),
|
|
|
|
|
InlineNode::Strikethrough(s) => inline_to_text_into(&s.children, out),
|
|
|
|
|
InlineNode::Code(c) => out.push_str(&c.content),
|
|
|
|
|
InlineNode::Superscript(s) => inline_to_text_into(&s.children, out),
|
|
|
|
|
InlineNode::Subscript(s) => inline_to_text_into(&s.children, out),
|
|
|
|
|
InlineNode::MathInline(m) => out.push_str(&m.content),
|
|
|
|
|
InlineNode::Color(c) => inline_to_text_into(&c.children, out),
|
|
|
|
|
InlineNode::WikiLink(w) => {
|
|
|
|
|
if let Some(d) = &w.description {
|
|
|
|
|
inline_to_text_into(d, out);
|
|
|
|
|
} else if let Some(p) = &w.target.path {
|
|
|
|
|
out.push_str(p);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
InlineNode::ExternalLink(e) => match &e.description {
|
|
|
|
|
Some(d) => inline_to_text_into(d, out),
|
|
|
|
|
None => out.push_str(&e.url),
|
|
|
|
|
},
|
|
|
|
|
InlineNode::Keyword(_) => {}
|
|
|
|
|
InlineNode::Transclusion(t) => out.push_str(t.alt.as_deref().unwrap_or(&t.url)),
|
|
|
|
|
InlineNode::RawUrl(r) => out.push_str(&r.url),
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ===== Filesystem walking =====
|
|
|
|
|
|
2026-05-26 22:00:03 -03:00
|
|
|
/// Strip a wiki file extension from a link target path without allocating.
|
|
|
|
|
///
|
|
|
|
|
/// `file_extension` may be `".wiki"` (with leading dot) or `"wiki"` (without).
|
|
|
|
|
/// Returns `path` unchanged when no extension is configured, the extension is
|
|
|
|
|
/// empty, or the path doesn't end with `.{ext}`.
|
|
|
|
|
pub fn strip_wiki_extension<'a>(path: &'a str, file_extension: Option<&str>) -> &'a str {
|
|
|
|
|
let ext = match file_extension {
|
|
|
|
|
Some(e) => e.trim_start_matches('.'),
|
|
|
|
|
None => return path,
|
|
|
|
|
};
|
|
|
|
|
if ext.is_empty() {
|
|
|
|
|
return path;
|
|
|
|
|
}
|
|
|
|
|
let dot_ext_len = ext.len() + 1; // +1 for the '.'
|
|
|
|
|
if path.len() > dot_ext_len
|
|
|
|
|
&& path.ends_with(ext)
|
|
|
|
|
&& path.as_bytes()[path.len() - dot_ext_len] == b'.'
|
|
|
|
|
{
|
|
|
|
|
&path[..path.len() - dot_ext_len]
|
|
|
|
|
} else {
|
|
|
|
|
path
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-11 00:27:06 +00:00
|
|
|
/// Recursively collect every `.wiki` file under `root`, skipping dotfiles
|
|
|
|
|
/// and dot-directories.
|
|
|
|
|
pub fn walk_wiki_files(root: &Path) -> Vec<PathBuf> {
|
|
|
|
|
let mut out = Vec::new();
|
|
|
|
|
let mut stack = vec![root.to_path_buf()];
|
|
|
|
|
while let Some(dir) = stack.pop() {
|
|
|
|
|
let entries = match std::fs::read_dir(&dir) {
|
|
|
|
|
Ok(e) => e,
|
|
|
|
|
Err(_) => continue,
|
|
|
|
|
};
|
|
|
|
|
for entry in entries.flatten() {
|
|
|
|
|
let path = entry.path();
|
|
|
|
|
let Some(name) = path.file_name() else {
|
|
|
|
|
continue;
|
|
|
|
|
};
|
|
|
|
|
if name.to_string_lossy().starts_with('.') {
|
|
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
let Ok(meta) = entry.metadata() else {
|
|
|
|
|
continue;
|
|
|
|
|
};
|
|
|
|
|
if meta.is_dir() {
|
|
|
|
|
stack.push(path);
|
|
|
|
|
} else if meta.is_file() && path.extension().and_then(|e| e.to_str()) == Some("wiki") {
|
|
|
|
|
out.push(path);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
out.sort();
|
|
|
|
|
out
|
|
|
|
|
}
|