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

1"""Wiki browse: shared page listing, reading, and resolution logic.""" 

2 

3from __future__ import annotations 

4 

5from dataclasses import dataclass, field 

6from datetime import date, datetime 

7from pathlib import Path 

8from typing import Any 

9 

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) 

19 

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 

23 

24 

25@dataclass 

26class WikiPageInfo: 

27 """Summary metadata for a wiki page.""" 

28 

29 slug: str 

30 title: str 

31 page_type: str 

32 source_count: int 

33 created_at: str 

34 

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 } 

44 

45 

46@dataclass 

47class WikiPageContent: 

48 """Full content of a wiki page with parsed frontmatter. Frontmatter values are JSON-safe.""" 

49 

50 slug: str 

51 title: str 

52 content: str 

53 frontmatter: dict[str, Any] = field(default_factory=dict) 

54 

55 

56def _json_safe(value: Any) -> Any: 

57 """yaml.safe_load returns datetime/date objects for date-like scalars; JSON needs strings. 

58 

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 

69 

70 

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")) 

76 

77 

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" 

88 

89 

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("\\", "/") 

94 

95 

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 

108 

109 

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. 

112 

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() 

121 

122 

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 ) 

139 

140 

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 

152 

153 

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")) 

159 

160 

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 

168 

169 

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 ] 

176 

177 

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)