docfriction 0.1.0__py3-none-any.whl

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.
@@ -0,0 +1,29 @@
1
+ """docfriction: automated friction logs for online documentation, scored with TypeSafe Jev."""
2
+
3
+ from ._version import __version__
4
+ from .evaluate import EvaluateOptions, Thresholds, evaluate_document
5
+ from .fetch import FetchError, load_document
6
+ from .jev import JevClient, JevError, MissingApiKeyError
7
+ from .models import Document, Finding, FrictionLog, Segment, StepLog
8
+ from .report import render_json, render_markdown
9
+ from .segment import segment_markdown
10
+
11
+ __all__ = [
12
+ "Document",
13
+ "EvaluateOptions",
14
+ "FetchError",
15
+ "Finding",
16
+ "FrictionLog",
17
+ "JevClient",
18
+ "JevError",
19
+ "MissingApiKeyError",
20
+ "Segment",
21
+ "StepLog",
22
+ "Thresholds",
23
+ "__version__",
24
+ "evaluate_document",
25
+ "load_document",
26
+ "render_json",
27
+ "render_markdown",
28
+ "segment_markdown",
29
+ ]
@@ -0,0 +1 @@
1
+ __version__ = "0.1.0"
docfriction/checks.py ADDED
@@ -0,0 +1,152 @@
1
+ """Deterministic checks that need no model. Jev is weak at counting and pattern
2
+ matching, so anything a regex or an HTTP request can answer is done here."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import ipaddress
7
+ import re
8
+ import socket
9
+ from collections.abc import Callable
10
+
11
+ import httpx
12
+
13
+ from ._version import __version__
14
+ from .models import STATIC_SOURCE, Finding, Segment
15
+
16
+ PLACEHOLDER_RE = re.compile(
17
+ r"YOUR_[A-Z0-9_]+"
18
+ r"|\{\{[^}]+\}\}"
19
+ r"|\bREPLACE[_ -]?ME\b"
20
+ r"|\bCHANGE[_ -]?ME\b"
21
+ r"|\bxxx+\b"
22
+ r"|\bTODO\b"
23
+ )
24
+ ANGLE_PLACEHOLDER_RE = re.compile(r"<[A-Za-z][\w-]*(?:[_-][\w-]+)*>")
25
+ MARKUP_LANGUAGES = frozenset({"html", "xml", "jsx", "tsx", "vue", "svelte", "xhtml", "svg"})
26
+ MIN_PROSE_CHARS = 40
27
+ DEAD_LINK_RETRY_STATUSES = frozenset({400, 403, 405})
28
+ DEFAULT_LINK_TIMEOUT_SECONDS = 10.0
29
+ ALLOWED_LINK_SCHEMES = frozenset({"http", "https"})
30
+ USER_AGENT = f"docfriction/{__version__} (+https://github.com/Cyvid7-Darus10/docfriction)"
31
+
32
+ Resolver = Callable[[str], tuple[str, ...]]
33
+
34
+
35
+ class BlockedHostError(httpx.RequestError):
36
+ """Raised by the link client when a URL points at a private or internal address."""
37
+
38
+
39
+ def find_placeholders(segment: Segment) -> tuple[str, ...]:
40
+ found: list[str] = []
41
+ for block in segment.code_blocks:
42
+ found.extend(PLACEHOLDER_RE.findall(block.content))
43
+ if block.language not in MARKUP_LANGUAGES:
44
+ found.extend(ANGLE_PLACEHOLDER_RE.findall(block.content))
45
+ return tuple(sorted(set(found)))
46
+
47
+
48
+ def static_findings(segment: Segment) -> tuple[Finding, ...]:
49
+ findings: list[Finding] = []
50
+ untagged = sum(1 for block in segment.code_blocks if not block.language)
51
+ if untagged:
52
+ findings.append(
53
+ Finding(
54
+ check="untagged_code_block",
55
+ source=STATIC_SOURCE,
56
+ detail=f"{untagged} code block(s) have no language tag, so readers cannot "
57
+ "tell shell from config or output",
58
+ )
59
+ )
60
+ if not segment.has_code and len(segment.prose) < MIN_PROSE_CHARS:
61
+ findings.append(
62
+ Finding(
63
+ check="stub_section",
64
+ source=STATIC_SOURCE,
65
+ detail="The heading has almost no content under it",
66
+ )
67
+ )
68
+ return tuple(findings)
69
+
70
+
71
+ def ip_literal(host: str) -> tuple[str, ...]:
72
+ """The host itself if it is an IP address (IPv6 brackets stripped), else empty."""
73
+ try:
74
+ ipaddress.ip_address(host.strip("[]"))
75
+ except ValueError:
76
+ return ()
77
+ return (host.strip("[]"),)
78
+
79
+
80
+ def resolve_host(host: str) -> tuple[str, ...]:
81
+ """All addresses a hostname resolves to via DNS."""
82
+ try:
83
+ infos = socket.getaddrinfo(host, None)
84
+ except socket.gaierror as exc:
85
+ raise BlockedHostError(f"{host} does not resolve") from exc
86
+ return tuple(dict.fromkeys(str(info[4][0]) for info in infos))
87
+
88
+
89
+ def is_public_address(address: str) -> bool:
90
+ parsed = ipaddress.ip_address(address)
91
+ return not (
92
+ parsed.is_private
93
+ or parsed.is_loopback
94
+ or parsed.is_link_local
95
+ or parsed.is_multicast
96
+ or parsed.is_reserved
97
+ or parsed.is_unspecified
98
+ )
99
+
100
+
101
+ def link_client(
102
+ *,
103
+ transport: httpx.BaseTransport | None = None,
104
+ timeout: float = DEFAULT_LINK_TIMEOUT_SECONDS,
105
+ allow_private_hosts: bool = False,
106
+ resolver: Resolver = resolve_host,
107
+ ) -> httpx.Client:
108
+ """One shared client for link checks. The request hook runs on every redirect hop,
109
+ so a public URL cannot bounce the checker into a private network."""
110
+
111
+ def guard(request: httpx.Request) -> None:
112
+ if request.url.scheme not in ALLOWED_LINK_SCHEMES:
113
+ raise BlockedHostError(f"{request.url} uses an unsupported scheme")
114
+ if allow_private_hosts:
115
+ return
116
+ host = request.url.host
117
+ addresses = ip_literal(host) or resolver(host)
118
+ if not all(is_public_address(address) for address in addresses):
119
+ raise BlockedHostError(f"{request.url} points at a private or internal address")
120
+
121
+ return httpx.Client(
122
+ follow_redirects=True,
123
+ headers={"User-Agent": USER_AGENT},
124
+ timeout=timeout,
125
+ transport=transport,
126
+ event_hooks={"request": [guard]},
127
+ )
128
+
129
+
130
+ def check_links(urls: tuple[str, ...], client: httpx.Client) -> tuple[Finding, ...]:
131
+ results = (_check_link(client, url) for url in urls)
132
+ return tuple(finding for finding in results if finding is not None)
133
+
134
+
135
+ def _check_link(client: httpx.Client, url: str) -> Finding | None:
136
+ try:
137
+ response = client.head(url)
138
+ if response.status_code in DEAD_LINK_RETRY_STATUSES:
139
+ # Some hosts reject HEAD; fetch headers only, never the body.
140
+ with client.stream("GET", url) as streamed:
141
+ response = streamed
142
+ except BlockedHostError as exc:
143
+ return Finding(check="blocked_link", source=STATIC_SOURCE, detail=str(exc))
144
+ except httpx.HTTPError as exc:
145
+ return _dead_link(f"{url} could not be reached ({type(exc).__name__})")
146
+ if response.status_code >= 400:
147
+ return _dead_link(f"{url} returned HTTP {response.status_code}")
148
+ return None
149
+
150
+
151
+ def _dead_link(detail: str) -> Finding:
152
+ return Finding(check="dead_link", source=STATIC_SOURCE, detail=detail)
docfriction/cli.py ADDED
@@ -0,0 +1,106 @@
1
+ """Command-line entry point: docfriction <url-or-file> [options]."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import sys
7
+ from pathlib import Path
8
+
9
+ from ._version import __version__
10
+ from .evaluate import EvaluateOptions, evaluate_document
11
+ from .fetch import FetchError, load_document
12
+ from .jev import DEFAULT_MODEL, JevClient, JevError
13
+ from .report import render_json, render_markdown
14
+ from .segment import segment_markdown
15
+
16
+ EXIT_OK = 0
17
+ EXIT_ERROR = 1
18
+ EXIT_THRESHOLD = 2
19
+
20
+
21
+ def build_parser() -> argparse.ArgumentParser:
22
+ parser = argparse.ArgumentParser(
23
+ prog="docfriction",
24
+ description="Write a friction log for a documentation page, scored step by step with Jev.",
25
+ )
26
+ parser.add_argument("source", help="URL of a docs page, or a local .md/.html file")
27
+ parser.add_argument("-o", "--out", type=Path, help="write the report here instead of stdout")
28
+ parser.add_argument("-f", "--format", choices=("md", "json"), default="md")
29
+ parser.add_argument("--model", default=DEFAULT_MODEL, help="Jev model id (default: jev-latest)")
30
+ parser.add_argument("--check-links", action="store_true", help="also HEAD every external link")
31
+ parser.add_argument(
32
+ "--allow-private-links",
33
+ action="store_true",
34
+ help="let --check-links request private, loopback, and link-local addresses",
35
+ )
36
+ parser.add_argument("--max-sections", type=int, help="only evaluate the first N sections")
37
+ parser.add_argument("--concurrency", type=int, default=4, help="parallel Jev calls")
38
+ parser.add_argument(
39
+ "--fail-on-severity",
40
+ type=float,
41
+ metavar="N",
42
+ help="exit 2 if any step's severity is at least N on the 0-3 scale",
43
+ )
44
+ parser.add_argument(
45
+ "--dry-run",
46
+ action="store_true",
47
+ help="print the detected steps and exit without calling Jev (no API key needed)",
48
+ )
49
+ parser.add_argument("--version", action="version", version=f"docfriction {__version__}")
50
+ return parser
51
+
52
+
53
+ def main(argv: list[str] | None = None) -> int:
54
+ args = build_parser().parse_args(argv)
55
+ try:
56
+ document = load_document(args.source)
57
+ except FetchError as exc:
58
+ return _fail(str(exc))
59
+ if args.dry_run:
60
+ return _dry_run(document.markdown, document.title, args.max_sections)
61
+ options = EvaluateOptions(
62
+ check_links=args.check_links,
63
+ allow_private_links=args.allow_private_links,
64
+ max_sections=args.max_sections,
65
+ concurrency=args.concurrency,
66
+ )
67
+ try:
68
+ with JevClient(model=args.model) as client:
69
+ log = evaluate_document(document, client, options)
70
+ except JevError as exc:
71
+ return _fail(str(exc))
72
+ report = render_json(log) if args.format == "json" else render_markdown(log)
73
+ _emit(report, args.out)
74
+ if args.fail_on_severity is not None and log.max_severity >= args.fail_on_severity:
75
+ print(
76
+ f"docfriction: max severity {log.max_severity:.2f} >= {args.fail_on_severity}",
77
+ file=sys.stderr,
78
+ )
79
+ return EXIT_THRESHOLD
80
+ return EXIT_OK
81
+
82
+
83
+ def _dry_run(markdown: str, title: str, max_sections: int | None) -> int:
84
+ segments = segment_markdown(markdown)[:max_sections]
85
+ print(f"{title}: {len(segments)} step(s)")
86
+ for segment in segments:
87
+ code = f", {len(segment.code_blocks)} code block(s)" if segment.has_code else ""
88
+ print(f" {segment.index + 1}. {segment.title} ({len(segment.prose)} chars{code})")
89
+ return EXIT_OK
90
+
91
+
92
+ def _emit(report: str, out: Path | None) -> None:
93
+ if out is None:
94
+ sys.stdout.write(report)
95
+ return
96
+ out.write_text(report, encoding="utf-8")
97
+ print(f"docfriction: wrote {out}", file=sys.stderr)
98
+
99
+
100
+ def _fail(message: str) -> int:
101
+ print(f"docfriction: {message}", file=sys.stderr)
102
+ return EXIT_ERROR
103
+
104
+
105
+ if __name__ == "__main__":
106
+ sys.exit(main())
@@ -0,0 +1,177 @@
1
+ """Walk a document step by step, ask Jev about each step, and assemble a friction log."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Mapping
6
+ from concurrent.futures import ThreadPoolExecutor
7
+ from dataclasses import dataclass, field
8
+ from datetime import datetime, timezone
9
+
10
+ import httpx
11
+
12
+ from .checks import check_links, link_client, static_findings
13
+ from .jev import JevClient
14
+ from .models import JEV_SOURCE, Answer, Document, Finding, FrictionLog, Segment, StepLog
15
+ from .rubric import (
16
+ ACTIONABLE_ONLY,
17
+ FRICTION_TYPES,
18
+ IS_ACTIONABLE,
19
+ NO_FRICTION,
20
+ NOUL_FAILURE_DETAIL,
21
+ build_questions,
22
+ build_state,
23
+ )
24
+ from .segment import segment_markdown
25
+
26
+ SENTIMENT_BANDS: tuple[tuple[str, float], ...] = (
27
+ ("smooth", 0.75),
28
+ ("pause", 1.5),
29
+ ("frustrated", 2.25),
30
+ )
31
+ BLOCKED = "blocked"
32
+ UNKNOWN_SENTIMENT = "unknown"
33
+
34
+
35
+ @dataclass(frozen=True)
36
+ class Thresholds:
37
+ """Where probabilities turn into findings. Tune per docs set; these are starting points."""
38
+
39
+ friction_min_probability: float = 0.5
40
+ noul_fail_below: float = 0.4
41
+ noul_review_below: float = 0.6
42
+ actionable_min: float = 0.5
43
+ low_confidence: float = 0.4
44
+
45
+
46
+ @dataclass(frozen=True)
47
+ class EvaluateOptions:
48
+ thresholds: Thresholds = field(default_factory=Thresholds)
49
+ check_links: bool = False
50
+ allow_private_links: bool = False
51
+ max_sections: int | None = None
52
+ concurrency: int = 4
53
+
54
+
55
+ def evaluate_document(
56
+ document: Document,
57
+ client: JevClient,
58
+ options: EvaluateOptions | None = None,
59
+ *,
60
+ link_transport: httpx.BaseTransport | None = None,
61
+ ) -> FrictionLog:
62
+ opts = options or EvaluateOptions()
63
+ segments = segment_markdown(document.markdown)[: opts.max_sections]
64
+ links = (
65
+ link_client(transport=link_transport, allow_private_hosts=opts.allow_private_links)
66
+ if opts.check_links
67
+ else None
68
+ )
69
+
70
+ def run(index: int) -> StepLog:
71
+ previous = segments[index - 1] if index else None
72
+ return evaluate_segment(segments[index], previous, document.title, client, opts, links)
73
+
74
+ try:
75
+ with ThreadPoolExecutor(max_workers=max(1, opts.concurrency)) as pool:
76
+ steps = tuple(pool.map(run, range(len(segments))))
77
+ finally:
78
+ if links is not None:
79
+ links.close()
80
+ return FrictionLog(
81
+ source=document.source,
82
+ title=document.title,
83
+ model=_model_name(steps, client.model),
84
+ generated_at=datetime.now(timezone.utc).isoformat(timespec="seconds"),
85
+ steps=steps,
86
+ )
87
+
88
+
89
+ def evaluate_segment(
90
+ segment: Segment,
91
+ previous: Segment | None,
92
+ page_title: str,
93
+ client: JevClient,
94
+ options: EvaluateOptions,
95
+ links: httpx.Client | None = None,
96
+ ) -> StepLog:
97
+ result = client.evaluate(build_state(segment, previous, page_title), build_questions(segment))
98
+ severity, jev_findings = interpret_answers(result.answers, options.thresholds)
99
+ link_findings = check_links(segment.links, links) if links is not None else ()
100
+ return StepLog(
101
+ segment=segment,
102
+ severity=severity,
103
+ sentiment=sentiment_for(severity),
104
+ findings=(*jev_findings, *static_findings(segment), *link_findings),
105
+ input_tokens=result.input_tokens,
106
+ output_tokens=result.output_tokens,
107
+ model=result.model,
108
+ )
109
+
110
+
111
+ def interpret_answers(
112
+ answers: Mapping[str, Answer], thresholds: Thresholds
113
+ ) -> tuple[float | None, tuple[Finding, ...]]:
114
+ severity_answer = answers.get("severity")
115
+ severity = float(severity_answer.value) if severity_answer else None
116
+ actionable = answers.get(IS_ACTIONABLE)
117
+ is_actionable = actionable is None or float(actionable.value) >= thresholds.actionable_min
118
+ findings = [
119
+ *_friction_type_findings(answers.get("friction_type"), thresholds),
120
+ *(
121
+ finding
122
+ for key, answer in answers.items()
123
+ if answer.type == "noul" and key in NOUL_FAILURE_DETAIL
124
+ if is_actionable or key not in ACTIONABLE_ONLY
125
+ for finding in _noul_findings(key, answer, thresholds)
126
+ ),
127
+ ]
128
+ return severity, tuple(findings)
129
+
130
+
131
+ def sentiment_for(severity: float | None) -> str:
132
+ if severity is None:
133
+ return UNKNOWN_SENTIMENT
134
+ for name, upper in SENTIMENT_BANDS:
135
+ if severity < upper:
136
+ return name
137
+ return BLOCKED
138
+
139
+
140
+ def _friction_type_findings(answer: Answer | None, thresholds: Thresholds) -> tuple[Finding, ...]:
141
+ if answer is None or answer.value == NO_FRICTION:
142
+ return ()
143
+ probability = answer.probability or 0.0
144
+ if probability < thresholds.friction_min_probability:
145
+ return ()
146
+ confidence = answer.confidence
147
+ return (
148
+ Finding(
149
+ check=str(answer.value),
150
+ source=JEV_SOURCE,
151
+ detail=FRICTION_TYPES.get(str(answer.value), str(answer.value)),
152
+ probability=probability,
153
+ confidence=confidence,
154
+ needs_review=confidence is not None and confidence < thresholds.low_confidence,
155
+ ),
156
+ )
157
+
158
+
159
+ def _noul_findings(key: str, answer: Answer, thresholds: Thresholds) -> tuple[Finding, ...]:
160
+ probability = float(answer.value)
161
+ if probability >= thresholds.noul_review_below:
162
+ return ()
163
+ return (
164
+ Finding(
165
+ check=key,
166
+ source=JEV_SOURCE,
167
+ detail=NOUL_FAILURE_DETAIL[key],
168
+ probability=probability,
169
+ confidence=answer.confidence,
170
+ needs_review=probability >= thresholds.noul_fail_below,
171
+ ),
172
+ )
173
+
174
+
175
+ def _model_name(steps: tuple[StepLog, ...], fallback: str) -> str:
176
+ """Prefer the resolved model id from the API (e.g. jev-1.13.0) over the alias."""
177
+ return next((step.model for step in steps if step.model), fallback)
docfriction/fetch.py ADDED
@@ -0,0 +1,124 @@
1
+ """Load a documentation page from a URL or a local file and normalise it to Markdown."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+
7
+ import httpx
8
+ from bs4 import BeautifulSoup, Tag
9
+ from markdownify import markdownify
10
+
11
+ from ._version import __version__
12
+ from .models import Document
13
+ from .segment import document_title
14
+
15
+ # Most specific first: on GitHub `article.markdown-body` is the README while `main`
16
+ # also wraps the file browser; on Sphinx sites `[role=main]` excludes the sidebar.
17
+ CONTENT_SELECTORS = ("article", ".markdown-body", "[role=main]", "main", "#content", ".content")
18
+ STRIP_TAGS = ("script", "style", "nav", "header", "footer", "aside", "noscript", "svg", "form")
19
+ HTML_SUFFIXES = frozenset({".html", ".htm"})
20
+ LANGUAGE_CLASS_PREFIXES = ("language-", "lang-")
21
+ USER_AGENT = f"docfriction/{__version__} (+https://github.com/Cyvid7-Darus10/docfriction)"
22
+ ACCEPT = "text/markdown, text/html;q=0.9, text/plain;q=0.8, */*;q=0.1"
23
+ DEFAULT_TIMEOUT_SECONDS = 20.0
24
+ MAX_PAGE_BYTES = 5 * 1024 * 1024
25
+
26
+
27
+ class FetchError(Exception):
28
+ pass
29
+
30
+
31
+ def load_document(
32
+ source: str,
33
+ *,
34
+ transport: httpx.BaseTransport | None = None,
35
+ timeout: float = DEFAULT_TIMEOUT_SECONDS,
36
+ ) -> Document:
37
+ if source.startswith(("http://", "https://")):
38
+ return _fetch_url(source, transport=transport, timeout=timeout)
39
+ path = Path(source)
40
+ if not path.is_file():
41
+ raise FetchError(f"source is neither a URL nor an existing file: {source}")
42
+ try:
43
+ text = path.read_text(encoding="utf-8")
44
+ except (OSError, UnicodeDecodeError) as exc:
45
+ raise FetchError(f"failed to read {source}: {exc}") from exc
46
+ if path.suffix.lower() in HTML_SUFFIXES:
47
+ return _from_html(source, text, fallback_title=path.name)
48
+ return Document(source=source, title=document_title(text) or path.name, markdown=text)
49
+
50
+
51
+ def _fetch_url(url: str, *, transport: httpx.BaseTransport | None, timeout: float) -> Document:
52
+ headers = {"User-Agent": USER_AGENT, "Accept": ACCEPT}
53
+ try:
54
+ with (
55
+ httpx.Client(
56
+ follow_redirects=True, headers=headers, timeout=timeout, transport=transport
57
+ ) as client,
58
+ client.stream("GET", url) as response,
59
+ ):
60
+ response.raise_for_status()
61
+ content_type = response.headers.get("content-type", "")
62
+ body = _read_capped(response, url)
63
+ except httpx.HTTPError as exc:
64
+ raise FetchError(f"failed to fetch {url}: {exc}") from exc
65
+ if not body.strip():
66
+ raise FetchError(f"{url} returned an empty body")
67
+ if _looks_like_html(content_type, body):
68
+ return _from_html(url, body, fallback_title=url)
69
+ return Document(source=url, title=document_title(body) or url, markdown=body)
70
+
71
+
72
+ def _read_capped(response: httpx.Response, url: str) -> str:
73
+ """Read at most MAX_PAGE_BYTES so a hostile or huge page cannot exhaust memory."""
74
+ declared = response.headers.get("content-length")
75
+ if declared and declared.isdigit() and int(declared) > MAX_PAGE_BYTES:
76
+ raise FetchError(f"{url} is larger than {MAX_PAGE_BYTES} bytes")
77
+ chunks: list[bytes] = []
78
+ total = 0
79
+ for chunk in response.iter_bytes():
80
+ total += len(chunk)
81
+ if total > MAX_PAGE_BYTES:
82
+ raise FetchError(f"{url} is larger than {MAX_PAGE_BYTES} bytes")
83
+ chunks.append(chunk)
84
+ return b"".join(chunks).decode(response.charset_encoding or "utf-8", errors="replace")
85
+
86
+
87
+ def _looks_like_html(content_type: str, body: str) -> bool:
88
+ head = body.lstrip()[:20].lower()
89
+ return "html" in content_type or head.startswith(("<!doctype", "<html"))
90
+
91
+
92
+ def _from_html(source: str, html: str, *, fallback_title: str) -> Document:
93
+ soup = BeautifulSoup(html, "html.parser")
94
+ page_title = soup.title.get_text(strip=True) if soup.title else ""
95
+ for tag in soup(STRIP_TAGS):
96
+ tag.decompose()
97
+ root = _content_root(soup)
98
+ markdown = markdownify(
99
+ str(root), heading_style="ATX", bullets="-", code_language_callback=_code_language
100
+ ).strip()
101
+ if not markdown:
102
+ raise FetchError(f"no readable content found in {source}")
103
+ title = document_title(markdown) or page_title or fallback_title
104
+ return Document(source=source, title=title, markdown=markdown)
105
+
106
+
107
+ def _content_root(soup: BeautifulSoup) -> Tag | BeautifulSoup:
108
+ for selector in CONTENT_SELECTORS:
109
+ found = soup.select_one(selector)
110
+ if found is not None and found.get_text(strip=True):
111
+ return found
112
+ return soup.body or soup
113
+
114
+
115
+ def _code_language(element: Tag) -> str:
116
+ classes = list(element.get("class") or [])
117
+ inner = element.find("code")
118
+ if isinstance(inner, Tag):
119
+ classes.extend(inner.get("class") or [])
120
+ for css_class in classes:
121
+ for prefix in LANGUAGE_CLASS_PREFIXES:
122
+ if css_class.startswith(prefix):
123
+ return css_class[len(prefix) :]
124
+ return ""