cppmanlite 0.1.1__tar.gz → 0.1.7__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: cppmanlite
3
- Version: 0.1.1
3
+ Version: 0.1.7
4
4
  Summary: Lightweight serverless C++ documentation lookup — pure Python, no C deps
5
5
  License-Expression: MIT
6
6
  Project-URL: Homepage, https://dive4dec.github.io/cppmanlite/
@@ -11,6 +11,7 @@ from __future__ import annotations
11
11
 
12
12
  import html
13
13
  import json
14
+ import math
14
15
  import re
15
16
  import textwrap
16
17
  from pathlib import Path
@@ -101,13 +102,127 @@ def _fetch_url_sync(url: str) -> str:
101
102
  # --------------------------------------------------------------------------- #
102
103
  # Search
103
104
  # --------------------------------------------------------------------------- #
105
+ #
106
+ # Deterministic scorer that mirrors site/app.js exactly (same tokenisation,
107
+ # alias table, field weights, AND penalty and sort). Replacing the old naive
108
+ # substring scorer so the Python package and the static site rank identically.
109
+ # Why not BM25/lunr here? There is no lunr dependency in the package (it must
110
+ # stay pure-stdlib / Pyodide-safe), and a hand-rolled scorer puts exact-title
111
+ # hits first in a fully predictable way.
112
+
113
+ _SEARCH_TOKEN_RE = re.compile(r"[a-z_][a-z0-9_]*|[0-9]+")
114
+
115
+ # cppreference-style aliases: map a common name to the canonical identifier
116
+ # used in page titles. (std::string == std::basic_string, ...)
117
+ _SEARCH_ALIAS = {
118
+ "string": "basic_string", "wstring": "basic_wstring",
119
+ "u8string": "basic_u8string", "u16string": "basic_u16string",
120
+ "u32string": "basic_u32string", "str": "basic_string", "wstr": "basic_wstring",
121
+ }
122
+
123
+ # field weights: (whole-token match, substring-inside-larger-token)
124
+ _W = {"title": (100, 40), "terms": (80, 32), "url": (40, 16), "snippet": (8, 3)}
125
+
126
+
127
+ def _tok_set(s: str) -> set:
128
+ return set(_SEARCH_TOKEN_RE.findall(s.lower()))
129
+
130
+
131
+ def _strip_ns(s: str) -> str:
132
+ return re.sub(r"^(std::)+", "", s)
133
+
134
+
135
+ def _strip_args(s: str) -> str:
136
+ return re.sub(r"\(.*\)\s*$", "", s).strip()
137
+
138
+
139
+ def _alias_expand(s: str) -> str:
140
+ parts = [p for p in re.split(r"[\s_]+", s) if p]
141
+ return "_".join(_SEARCH_ALIAS.get(p, p) for p in parts)
142
+
143
+
144
+ def _match_tok(tok: str, s_low: str, tokset: set, full: int, sub: int) -> int:
145
+ if tok in tokset:
146
+ return full
147
+ if tok in s_low:
148
+ return sub
149
+ return 0
150
+
151
+
152
+ def _term_score(tok: str, term_freq: dict, terms_low: str) -> float:
153
+ # base "terms" weight, scaled up (log) by how often the identifier appears
154
+ # in the page body → the defining page outranks a page that just mentions it.
155
+ n = term_freq.get(tok, 0)
156
+ if n:
157
+ return _W["terms"][0] * (1 + math.log2(n))
158
+ if tok in terms_low:
159
+ return _W["terms"][1]
160
+ return 0
161
+
162
+
163
+ def _doc_score(meta: dict, search_toks: list, whole_cands: list) -> float:
164
+ score = 0
165
+ matched = 0
166
+ # Whole-title relations: EXACT always; PREFIX only for specific/compound queries.
167
+ for wc in whole_cands:
168
+ if not wc or len(wc) < 2:
169
+ continue
170
+ d_title = _strip_ns(meta["t_low"])
171
+ if d_title == wc:
172
+ score += 1000
173
+ else:
174
+ specific = len(search_toks) >= 2 or "_" in wc or len(wc) >= 8
175
+ if specific and d_title.startswith(wc + " "):
176
+ score += 400
177
+ for tok in search_toks:
178
+ s = max(
179
+ _match_tok(tok, meta["t_low"], meta["t_tok"], _W["title"][0], _W["title"][1]),
180
+ _term_score(tok, meta["term_freq"], meta["terms_low"]),
181
+ _match_tok(tok, meta["u_low"], meta["u_tok"], _W["url"][0], _W["url"][1]),
182
+ _match_tok(tok, meta["s_low"], meta["s_tok"], _W["snippet"][0], _W["snippet"][1]),
183
+ )
184
+ if s > 0:
185
+ score += s
186
+ matched += 1
187
+ # AND semantics: penalise if not every query token matched somewhere.
188
+ if len(search_toks) > 1 and matched < len(search_toks):
189
+ score *= matched / len(search_toks)
190
+ return score
191
+
192
+
193
+ def _build_meta(entry: dict) -> dict:
194
+ t = entry.get("title", "")
195
+ u = entry.get("url", "")
196
+ s = entry.get("snippet", "")
197
+ # terms is a {identifier: count} map; tolerate an older list format.
198
+ terms = entry.get("terms") or {}
199
+ if isinstance(terms, list):
200
+ term_freq = {t: 1 for t in terms}
201
+ else:
202
+ term_freq = terms
203
+ t_low = t.lower()
204
+ return {
205
+ "t_low": t_low,
206
+ "u_low": u.lower(),
207
+ "s_low": s.lower(),
208
+ "t_tok": _tok_set(t),
209
+ "u_tok": _tok_set(u),
210
+ "s_tok": _tok_set(s),
211
+ "term_freq": term_freq,
212
+ "terms_low": " ".join(term_freq.keys()),
213
+ "title_len": len(t_low),
214
+ }
104
215
 
105
216
 
106
217
  def search(query: str, limit: int = 20) -> list[dict[str, str]]:
107
218
  """Search C++ documentation pages.
108
219
 
220
+ Deterministic scorer (mirrors the static site): exact-title matches rank
221
+ first, then alias titles, then identifiers found in a page's body (e.g.
222
+ ``int64_t`` → Fixed width integer types), then URL/snippet substring hits.
223
+
109
224
  Args:
110
- query: Search term (e.g. "vector", "std::sort", "shared_ptr").
225
+ query: Search term (e.g. "vector", "std::max", "int64_t", "shared_ptr").
111
226
  limit: Maximum number of results.
112
227
 
113
228
  Returns:
@@ -116,27 +231,40 @@ def search(query: str, limit: int = 20) -> list[dict[str, str]]:
116
231
  idx = _load_index()
117
232
  if not idx:
118
233
  return []
119
- q = query.lower().strip()
120
- # Normalise std:: prefix
121
- q_norm = re.sub(r"^std::", "", q)
122
- results = []
234
+ q_norm = query.lower().strip()
235
+ if not q_norm:
236
+ return []
237
+
238
+ q_toks = _SEARCH_TOKEN_RE.findall(q_norm)
239
+ # drop a leading "std" namespace token (std::vector → ["std","vector"])
240
+ if q_toks and q_toks[0] == "std" and q_norm.startswith("std::"):
241
+ q_toks = q_toks[1:]
242
+ if not q_toks:
243
+ return []
244
+
245
+ # expand each token through the alias table, dedup, preserve order
246
+ seen = set()
247
+ search_toks = []
248
+ for t in q_toks:
249
+ for cand in (t, _SEARCH_ALIAS.get(t, "")):
250
+ if cand and cand not in seen:
251
+ seen.add(cand)
252
+ search_toks.append(cand)
253
+
254
+ q_title_whole = _strip_args(_strip_ns(q_norm))
255
+ whole_cands = []
256
+ for c in (q_title_whole, _alias_expand(q_title_whole)):
257
+ if len(c) >= 2 and c not in whole_cands:
258
+ whole_cands.append(c)
259
+
260
+ scored = []
123
261
  for entry in idx:
124
- title = entry.get("title", "").lower()
125
- url = entry.get("url", "").lower()
126
- # Score: exact match > starts with > contains in title > contains in URL
127
- score = 0
128
- if title == q or title == q_norm:
129
- score = 100
130
- elif title.startswith(q) or title.startswith(q_norm):
131
- score = 80
132
- elif q in title or q_norm in title:
133
- score = 60
134
- elif q in url or q_norm in url:
135
- score = 40
262
+ score = _doc_score(_build_meta(entry), search_toks, whole_cands)
136
263
  if score > 0:
137
- results.append({**entry, "_score": score})
138
- results.sort(key=lambda x: (-x["_score"], x.get("title", "")))
139
- return [{k: v for k, v in r.items() if k != "_score"} for r in results[:limit]]
264
+ scored.append((score, entry))
265
+
266
+ scored.sort(key=lambda x: (-x[0], len(x[1].get("title", "").lower()), x[1].get("url", "")))
267
+ return [entry for _, entry in scored[:limit]]
140
268
 
141
269
 
142
270
  def list_pages(limit: int = 0) -> list[dict[str, str]]:
@@ -150,7 +278,7 @@ def list_pages(limit: int = 0) -> list[dict[str, str]]:
150
278
  # --------------------------------------------------------------------------- #
151
279
 
152
280
  _CONTENT_RE = re.compile(
153
- r'<div id="mw-content-text"[^>]*>(.*?)(?:</div>\s*<!--|\Z)',
281
+ r'<div class="mw-content-ltr mw-parser-output"[^>]*>(.*?)(?:</div>\s*<!--|\Z)',
154
282
  re.DOTALL,
155
283
  )
156
284
  _SCRIPT_RE = re.compile(r"<script[^>]*>.*?</script>", re.DOTALL)
@@ -349,12 +477,64 @@ def _format_search_html(results: list[dict[str, str]]) -> str:
349
477
  )
350
478
 
351
479
 
352
- def _format_page_html(content: str) -> str:
480
+ def _format_page_html(content: str, page_url: str = "") -> str:
481
+ """Format page content for Jupyter display with working links.
482
+
483
+ In a **trusted** notebook, Jupyter renders external https:// links
484
+ with target=\"_blank\" — clicking opens cppreference.com in a new tab.
485
+ In an untrusted notebook, the sanitizer strips external hrefs to '#'.
486
+
487
+ Links:
488
+ - Navigation links → absolute https://en.cppreference.com/w/... + target=\"_blank\"
489
+ - Edit links (<a href=\".../index.php?...action=edit\">) → unwrapped (text kept, <a> removed)
490
+ - Anchor links (href=\"#...\") → left as-is (in-page navigation)
491
+ """
492
+ from urllib.parse import urljoin
493
+
494
+ base_href = (
495
+ f"https://en.cppreference.com/w/{page_url}" if page_url
496
+ else "https://en.cppreference.com/w/"
497
+ )
498
+
499
+ # 1. Remove edit links entirely: <a ... href=".../index.php?...action=edit...">text</a> → text
500
+ content = re.sub(
501
+ r'<a [^>]*href="[^"]*index\.php[^"]*action=edit[^"]*"[^>]*>(.*?)</a>',
502
+ r'\1',
503
+ content,
504
+ flags=re.DOTALL,
505
+ )
506
+
507
+ # 2. Rewrite remaining hrefs
508
+ def _rewrite_href(m: re.Match) -> str:
509
+ href = m.group(1)
510
+
511
+ # Skip javascript: URLs — neutralize
512
+ if href.startswith("javascript:"):
513
+ return 'href="#" onclick="return false"'
514
+
515
+ # Skip anchors (in-page navigation) — keep as-is
516
+ if href.startswith("#"):
517
+ return f'href="{href}"'
518
+
519
+ # Resolve to absolute URL if needed
520
+ if not href.startswith("http://") and not href.startswith("https://"):
521
+ href = urljoin(base_href, href)
522
+
523
+ return f'href="{html.escape(href)}" target="_blank" rel="noopener"'
524
+
525
+ content = re.sub(r'href="([^"]*)"', _rewrite_href, content)
526
+
353
527
  return (
354
- '<div style="max-height:600px;overflow:auto;'
528
+ '<div class="cppmanlite-content" '
529
+ 'style="max-height:600px;overflow:auto;'
355
530
  'border:1px solid #ddd;padding:16px;font-size:14px">'
356
531
  + content
357
- + "</div>"
532
+ + '</div>'
533
+ + '<p style="font-size:11px;color:#888;margin-top:4px">'
534
+ + 'Links open cppreference.com in a new tab. '
535
+ + 'If links don\'t work, trust this notebook: '
536
+ + '<b>File → Trust Notebook</b>'
537
+ + '</p>'
358
538
  )
359
539
 
360
540
 
@@ -395,7 +575,7 @@ def _man_sync(query: str) -> None:
395
575
  if _is_jupyter():
396
576
  from IPython.display import HTML, display
397
577
 
398
- display(HTML(_format_page_html(content)))
578
+ display(HTML(_format_page_html(content, page_url=url)))
399
579
  else:
400
580
  # Terminal: print formatted text
401
581
  print(f"\n{'=' * 80}\n{title}\n{'=' * 80}\n")
@@ -414,7 +594,7 @@ async def _man_async(query: str) -> None:
414
594
  if _is_jupyter():
415
595
  from IPython.display import HTML, display
416
596
 
417
- display(HTML(_format_page_html(content)))
597
+ display(HTML(_format_page_html(content, page_url=url)))
418
598
  else:
419
599
  # Pyodide console / terminal
420
600
  print(f"\n{'=' * 80}\n{title}\n{'=' * 80}\n")