Coverage for src/lilbee/wiki/browse.py: 100%
103 statements
« prev ^ index » next coverage.py v7.15.2, created at 2026-08-14 11:46 +0000
« prev ^ index » next coverage.py v7.15.2, created at 2026-08-14 11:46 +0000
1"""Wiki browse: shared page listing, reading, and resolution logic."""
3from __future__ import annotations
5from dataclasses import dataclass, field
6from datetime import date, datetime
7from pathlib import Path
8from typing import Any
10from lilbee.core.security import validate_path_within
11from lilbee.wiki.grammar import CODE_FENCE_RE, H1_RE
12from lilbee.wiki.index import parse_source_count
13from lilbee.wiki.shared import (
14 SUBDIR_TO_TYPE,
15 WIKI_CONTENT_SUBDIRS,
16 WikiSubdir,
17 parse_frontmatter,
18)
20# Wiki paths under the root take the form ``<subdir>/<slug>.md``; only paths
21# with at least this many components carry a meaningful page type.
22_WIKI_PATH_MIN_PARTS = 2
25@dataclass
26class WikiPageInfo:
27 """Summary metadata for a wiki page."""
29 slug: str
30 title: str
31 page_type: str
32 source_count: int
33 created_at: str
35 def to_dict(self) -> dict[str, Any]:
36 """Serialize to a plain dict suitable for JSON responses."""
37 return {
38 "slug": self.slug,
39 "title": self.title,
40 "page_type": self.page_type,
41 "source_count": self.source_count,
42 "created_at": self.created_at,
43 }
46@dataclass
47class WikiPageContent:
48 """Full content of a wiki page with parsed frontmatter. Frontmatter values are JSON-safe."""
50 slug: str
51 title: str
52 content: str
53 frontmatter: dict[str, Any] = field(default_factory=dict)
56def _json_safe(value: Any) -> Any:
57 """yaml.safe_load returns datetime/date objects for date-like scalars; JSON needs strings.
59 Recurses into lists and dicts so nested frontmatter (provenance blocks,
60 source lists) is safe too.
61 """
62 if isinstance(value, (datetime, date)):
63 return value.isoformat()
64 if isinstance(value, dict):
65 return {key: _json_safe(item) for key, item in value.items()}
66 if isinstance(value, list):
67 return [_json_safe(item) for item in value]
68 return value
71def list_md_files(directory: Path) -> list[Path]:
72 """Return sorted markdown files in a directory (non-recursive)."""
73 if not directory.is_dir():
74 return []
75 return sorted(directory.glob("*.md"))
78def _page_type_from_path(path: Path, wiki_root: Path) -> str:
79 """Determine page type from its location relative to wiki root."""
80 try:
81 relative = path.relative_to(wiki_root)
82 except ValueError:
83 return "unknown"
84 parts = relative.parts
85 if len(parts) >= _WIKI_PATH_MIN_PARTS:
86 return SUBDIR_TO_TYPE.get(parts[0], "unknown")
87 return "unknown"
90def page_slug(path: Path, wiki_root: Path) -> str:
91 """The slug the read surfaces accept for a wiki page path."""
92 relative = path.relative_to(wiki_root)
93 return str(relative.with_suffix("")).replace("\\", "/")
96def _extract_h1_title(text: str) -> str | None:
97 """Return the first top-level heading from markdown body, ignoring fenced code blocks."""
98 in_fence = False
99 for line in text.splitlines():
100 if CODE_FENCE_RE.match(line):
101 in_fence = not in_fence
102 continue
103 if in_fence:
104 continue
105 if m := H1_RE.match(line):
106 return m.group(1).strip()
107 return None
110def _resolve_page_title(fm: dict[str, Any], text: str, path: Path) -> str:
111 """Pick a page title. Frontmatter wins; body H1 beats slug-title-case fallback.
113 Wiki generation does not emit a frontmatter title today, so without the H1
114 step every page would render as the slug (e.g. 'Cv Manual' for cv-manual.md).
115 """
116 if (fm_title := fm.get("title")) is not None:
117 return str(fm_title)
118 if (h1 := _extract_h1_title(text)) is not None:
119 return h1
120 return path.stem.replace("-", " ").title()
123def build_page_info(path: Path, wiki_root: Path) -> WikiPageInfo:
124 """Build a WikiPageInfo from a markdown file on disk."""
125 text = path.read_text(encoding="utf-8")
126 fm = parse_frontmatter(text)
127 slug = page_slug(path, wiki_root)
128 title = _resolve_page_title(fm, text, path)
129 page_type = _page_type_from_path(path, wiki_root)
130 source_count = parse_source_count(text)
131 created_at = str(_json_safe(fm.get("generated_at", "")))
132 return WikiPageInfo(
133 slug=slug,
134 title=title,
135 page_type=page_type,
136 source_count=source_count,
137 created_at=created_at,
138 )
141def find_page(wiki_root: Path, slug: str) -> Path | None:
142 """Resolve a slug to a wiki page path, or None if not found.
143 Validates the resolved path stays within wiki_root to prevent
144 path traversal attacks.
145 """
146 candidate = wiki_root / f"{slug}.md"
147 try:
148 validate_path_within(candidate, wiki_root)
149 except ValueError:
150 return None
151 return candidate if candidate.is_file() else None
154def _list_md_files_recursive(directory: Path) -> list[Path]:
155 """Sorted markdown files under *directory* at any depth."""
156 if not directory.is_dir():
157 return []
158 return sorted(directory.rglob("*.md"))
161def list_pages(wiki_root: Path) -> list[WikiPageInfo]:
162 """List all wiki pages under the content subdirs at any nesting depth."""
163 pages: list[WikiPageInfo] = []
164 for subdir in WIKI_CONTENT_SUBDIRS:
165 for path in _list_md_files_recursive(wiki_root / subdir):
166 pages.append(build_page_info(path, wiki_root))
167 return pages
170def list_draft_pages(wiki_root: Path) -> list[WikiPageInfo]:
171 """List draft pages that failed the quality gate (recurses into per-source dirs)."""
172 return [
173 build_page_info(path, wiki_root)
174 for path in _list_md_files_recursive(wiki_root / WikiSubdir.DRAFTS)
175 ]
178def read_page(wiki_root: Path, slug: str) -> WikiPageContent | None:
179 """Read a wiki page's content and parsed frontmatter.
180 Returns None if the page does not exist or the slug escapes wiki_root.
181 """
182 path = find_page(wiki_root, slug)
183 if path is None:
184 return None
185 text = path.read_text(encoding="utf-8")
186 fm = parse_frontmatter(text)
187 title = _resolve_page_title(fm, text, path)
188 frontmatter = {key: _json_safe(value) for key, value in fm.items()}
189 return WikiPageContent(slug=slug, title=title, content=text, frontmatter=frontmatter)