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.
- {cppmanlite-0.1.1 → cppmanlite-0.1.7}/PKG-INFO +1 -1
- {cppmanlite-0.1.1 → cppmanlite-0.1.7}/cppmanlite/core.py +206 -26
- cppmanlite-0.1.7/cppmanlite/data/index.json +1 -0
- {cppmanlite-0.1.1 → cppmanlite-0.1.7}/cppmanlite.egg-info/PKG-INFO +1 -1
- {cppmanlite-0.1.1 → cppmanlite-0.1.7}/pyproject.toml +1 -1
- cppmanlite-0.1.1/cppmanlite/data/index.json +0 -1
- {cppmanlite-0.1.1 → cppmanlite-0.1.7}/LICENSE +0 -0
- {cppmanlite-0.1.1 → cppmanlite-0.1.7}/README.md +0 -0
- {cppmanlite-0.1.1 → cppmanlite-0.1.7}/cppmanlite/__init__.py +0 -0
- {cppmanlite-0.1.1 → cppmanlite-0.1.7}/cppmanlite.egg-info/SOURCES.txt +0 -0
- {cppmanlite-0.1.1 → cppmanlite-0.1.7}/cppmanlite.egg-info/dependency_links.txt +0 -0
- {cppmanlite-0.1.1 → cppmanlite-0.1.7}/cppmanlite.egg-info/requires.txt +0 -0
- {cppmanlite-0.1.1 → cppmanlite-0.1.7}/cppmanlite.egg-info/top_level.txt +0 -0
- {cppmanlite-0.1.1 → cppmanlite-0.1.7}/setup.cfg +0 -0
|
@@ -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::
|
|
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
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
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
|
-
|
|
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
|
-
|
|
138
|
-
|
|
139
|
-
|
|
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
|
|
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
|
|
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
|
-
+
|
|
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")
|