From 73b6a7c6b8900a3e2ab8528c3c149ffc0d4dda7a Mon Sep 17 00:00:00 2001 From: Shivam Saini <51438830+shivamsn97@users.noreply.github.com> Date: Tue, 7 Jul 2026 19:03:58 +0530 Subject: [PATCH] =?UTF-8?q?fix(devserver):=20llms=20=E2=80=94=20RFC-compli?= =?UTF-8?q?ant=20Accept=20negotiation,=20absolute=20llms.txt,=20.md=20link?= =?UTF-8?q?=20rewriting?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - wants_markdown() now parses Accept per RFC 9110: exact media-type matching (no substring hits), q-values honored, q=0 excluded — 'text/html, text/markdown;q=0' gets HTML, 'text/markdownish' no longer serves markdown. Malformed headers fall back to HTML. - The generated /llms.txt emits absolute URLs and links .md only for pages whose markdown actually resolves; pages that would redirect are listed at their canonical HTML URL instead of a dead-end .md. - Auto-converted markdown rewrites internal page links to their .md renditions (query strings/fragments preserved; external, mailto:, /api/, asset, and already-.md links untouched). Public util gains html_to_markdown(..., rewrite_links=True); wrap_markdown hooks see the rewritten markdown. - Sync llms_txt hooks now run in a worker thread so hooks calling ctx.render_default() never block the event loop; async-hook discipline documented on render_default. Suite: 2247 passed, coverage 96.47%. Ruff clean. --- docs/changelog.md | 3 + docs/guides/llms.md | 44 +++++- pyxle/devserver/llms.py | 293 +++++++++++++++++++++++++++++++---- tests/devserver/test_llms.py | 272 +++++++++++++++++++++++++++++--- 4 files changed, 560 insertions(+), 52 deletions(-) diff --git a/docs/changelog.md b/docs/changelog.md index 60d3276..1e31d89 100644 --- a/docs/changelog.md +++ b/docs/changelog.md @@ -19,6 +19,9 @@ Release notes for Pyxle. While we're in beta (`0.x`), minor versions may include - **Documented where pyxle-auth accounts live.** A new [Where accounts live](plugins/pyxle-auth.md#where-accounts-live) section covers the SQLite database auto-created at `data/app.db` on first run (no manual migration step) and how to point at a production database via pyxle-db's `url` setting; the [pyxle-db settings docs](plugins/pyxle-db.md#plugin-settings) now also state that the file and its directory are created automatically. - **Honest scope note on `pyxle check`.** The [CLI reference](reference/cli.md#pyxle-check) now spells out that a green check validates syntax and semantics but does not prove a page renders — runtime-only mistakes (e.g. a component reading a loader key that doesn't exist) surface when the page loads, not in `check`. It also notes that as of 0.7.0 the JSX level works out of the box, since pyxle-langkit ships with the framework (previously the `[langkit]` extra). - **Prerequisites up front.** The [Introduction](getting-started/introduction.md) now says on page one that Pyxle needs Python 3.10+ (3.12 recommended) and Node.js 18+ with npm, matching [Installation](getting-started/installation.md). +- **`Accept: text/markdown` negotiation now follows RFC 9110.** The check used to be a substring match on the raw `Accept` header, which ignored q-values entirely — `text/html, text/markdown;q=0` served Markdown even though `q=0` means *not acceptable*, and `text/html;q=0.9, text/markdown;q=0.8` served Markdown even though HTML was preferred. The header is now properly parsed: media types match by exact type/subtype token (no substrings — `text/markdownish` no longer counts), q-values are honoured (`q=0` excludes; default `1.0`, clamped to `0..1`), wildcards like `*/*` never select Markdown, ties go to Markdown, and malformed headers never raise — they fall back to HTML. Since negotiated responses carry `Vary: Accept`, shared caches now key on the correct variant. New public helper: `pyxle.devserver.llms.markdown_is_acceptable(accept)`. See [AI accessibility → Negotiation rules](guides/llms.md#negotiation-rules). +- **The generated `/llms.txt` no longer advertises Markdown that isn't there.** The default index used to emit relative links and to link every concrete page's `.md` URL — including pages whose `.md` URL just `307`-redirects to HTML. It now emits **absolute URLs** derived from the request's scheme and host (proxy headers respected), links `.md` only for pages whose Markdown actually resolves (a co-located `.md`, a `to_markdown` handler, an ancestor `llms.py` — or every page when `autoConvert` is on), and lists the rest at their canonical HTML URL. Custom `llms_txt` hooks are unaffected; `ctx.render_default()` returns the improved index. See [AI accessibility → The /llms.txt index](guides/llms.md#the-llmstxt-index). +- **Converted Markdown keeps agents on the Markdown channel** (breaking behavior change). `autoConvert` output — and the public `html_to_markdown()` helper — now rewrites internal page links to their `.md` renditions: `[About](/about)` becomes `[About](/about.md)`, preserving query strings and fragments (`/about?x=1#y` → `/about.md?x=1#y`). External URLs, `mailto:`/`tel:` links, `/api/` routes, asset paths with a file extension, and links already ending in `.md` are untouched, and `wrap_markdown` hooks receive the rewritten Markdown. **Breaking:** `html_to_markdown()` rewrites by default — pass `rewrite_links=False` for the old verbatim behavior. See [AI accessibility → autoConvert](guides/llms.md#autoconvert-the-lossy-fallback). ## 0.6.1 — 2026-07-01 diff --git a/docs/guides/llms.md b/docs/guides/llms.md index 9c655f1..881c9cf 100644 --- a/docs/guides/llms.md +++ b/docs/guides/llms.md @@ -9,7 +9,7 @@ Every page of a Pyxle app can serve a clean **Markdown** rendition of itself — That single line gives you: - **Per-page Markdown** at each URL with `.md` appended — `/docs/routing` → `/docs/routing.md`. -- **Content negotiation** — the *same* URL returns Markdown to any request that sends `Accept: text/markdown`. Browsers never send it, so humans are unaffected. +- **Content negotiation** — the *same* URL returns Markdown to any request whose `Accept` header asks for `text/markdown`, negotiated with proper [RFC 9110](https://www.rfc-editor.org/rfc/rfc9110#section-12.5.1) q-values. Browsers never send it, so humans are unaffected. - **An `/llms.txt` index** — the [llms.txt](https://llmstxt.org/) convention: a Markdown map of your site. - **Discovery headers** on every response — `Link: ; rel="llms-txt"` and `X-Llms-Txt: /llms.txt` — so an agent finds the index without parsing HTML. @@ -130,6 +130,8 @@ Returning `None` **declines** and defers to the next ancestor (and ultimately to If nothing above resolves and you've set `"autoConvert": true`, Pyxle renders the page and converts its HTML to Markdown with a small, **dependency-free** converter. It's **off by default** and deliberately best-effort: headings, paragraphs, lists, links, emphasis, and code survive; layout chrome, tables, and rich components may not. Treat it as "something is better than a redirect" — prefer an authored `.md` or a handler for anything you care about. +Converted pages keep agents **on the Markdown channel**: internal links to other pages are rewritten to their `.md` renditions — `[About](/about)` becomes `[About](/about.md)`, with query strings and fragments preserved (`/about?x=1#y` → `/about.md?x=1#y`). External URLs, `mailto:`/`tel:` links, `/api/` routes, asset paths with a file extension, and links already ending in `.md` are left untouched. See [`html_to_markdown`](#ctxrender_html--post-processing-the-rendered-page) for the same behavior in your own handlers. + ### The redirect fallback With the feature on but no Markdown source and `autoConvert` off, `/.md` returns a **`307` redirect** to `/`. An agent that guesses a `.md` URL lands on the real page instead of a 404. @@ -177,9 +179,11 @@ from pyxle.devserver.llms import html_to_markdown # the built-in converter async def to_markdown(ctx): html = await ctx.render_html() - return html_to_markdown(html) # roughly what autoConvert does, but on your terms + return html_to_markdown(html) # what autoConvert does, but on your terms ``` +`html_to_markdown` rewrites internal page links to their `.md` renditions by default (exactly like [autoConvert](#autoconvert-the-lossy-fallback)); pass `rewrite_links=False` to keep every `href` verbatim. + It's lazy and potentially expensive (a full SSR pass), so only call it when you need it. When you want the page's *data* rather than its rendered HTML, reach for `ctx.run_loader()` instead — it's much cheaper. ### `ctx.run_loader()` — the loader's data, without the render @@ -220,7 +224,7 @@ def wrap_markdown(ctx, markdown): return f"{header}\n{markdown}" ``` -Because it runs on Markdown from *every* source (co-located files, `to_markdown` handlers, `autoConvert`), the framing is defined **once** and applied everywhere. Return `None` to leave the Markdown untouched. And because it's applied at serve time — not baked into your source — your `/llms-full.txt` corpus and the raw `.md` files stay clean. +Because it runs on Markdown from *every* source (co-located files, `to_markdown` handlers, `autoConvert`), the framing is defined **once** and applied everywhere — and it always receives the *final* resolved Markdown, so `autoConvert` output arrives with its internal links already rewritten to `.md`. Return `None` to leave the Markdown untouched. And because it's applied at serve time — not baked into your source — your `/llms-full.txt` corpus and the raw `.md` files stay clean. --- @@ -230,7 +234,12 @@ Because it runs on Markdown from *every* source (co-located files, `to_markdown` 1. a **static `public/llms.txt`** (served by the static-asset layer before anything else) — full manual control; 2. a **`llms_txt`** function in the root `pages/llms.py` — generate it dynamically; -3. a **generated** default: an `H1` plus a `## Pages` list linking every concrete (non-parameterised) page's `.md`. +3. a **generated** default: an `H1` plus a `## Pages` list of every concrete (non-parameterised) page. + +The generated default is precise about what it advertises: + +- **Links are absolute**, derived from the request's scheme and host (`https://example.com/about.md`) — so the index still points home when it's saved to disk or pasted into a chat. Behind a reverse proxy, run uvicorn with proxy-header support (`pyxle serve` does) so the advertised origin is the public one. +- **A page is linked at its `.md` URL only when that URL actually serves Markdown.** Each page is checked against the same [resolution ladder](#how-a-pages-markdown-is-resolved) `.md` requests use: a co-located `.md` file, a page-local `to_markdown`, or an ancestor `llms.py` handler qualifies it — and with `autoConvert` on, every page qualifies. A page with no source is listed at its **canonical HTML URL** instead, so the index never advertises a `.md` link that would just redirect. For a site with dynamic content — docs, a blog, a catalog — the generated default can't enumerate your dynamic routes, so provide a `llms_txt` hook: @@ -261,9 +270,30 @@ Each entry in `ctx.pages` is an **`LlmsPageInfo`** — `path` (e.g. `/about`), ` Beyond the `.md` URLs, two things make the feature work for agents that don't append `.md`: -- **`Accept: text/markdown` negotiation.** A request to the *canonical* URL (`/docs/routing`) that includes `text/markdown` in its `Accept` header gets the Markdown, resolved through the exact same ladder. Browsers never send that header, so this is invisible to human visitors. The response carries `Vary: Accept` so shared caches key on it correctly. +- **`Accept: text/markdown` negotiation.** A request to the *canonical* URL (`/docs/routing`) whose `Accept` header prefers `text/markdown` gets the Markdown, resolved through the exact same ladder. Browsers never ask for it, so this is invisible to human visitors. The response carries `Vary: Accept` so shared caches key on it correctly. - **Discovery headers.** Every response advertises the index: `Link: ; rel="llms-txt"` and `X-Llms-Txt: /llms.txt`. An agent can find your `llms.txt` from any page without parsing the body. +### Negotiation rules + +The `Accept` header is negotiated per [RFC 9110 §12.5.1](https://www.rfc-editor.org/rfc/rfc9110#section-12.5.1). Markdown is served when **both** hold: + +1. `text/markdown` appears as an **exact media type** with `q > 0`. Matching is by type/subtype token, case-insensitive — never by substring — and wildcards (`*/*`, `text/*`) never select Markdown, so ordinary browser headers keep getting HTML. +2. Its q-weight is **at least** the effective weight of `text/html` (which *does* pick up wildcard ranges). Ties go to Markdown — the client asked for it explicitly. + +In practice: + +| `Accept` header | Serves | Why | +|---|---|---| +| `text/markdown` | Markdown | explicit, q defaults to 1.0 | +| `text/markdown, text/html` | Markdown | equal weight — explicit ask wins the tie | +| `text/html;q=0.9, text/markdown;q=0.8` | HTML | HTML weighted higher | +| `text/html, text/markdown;q=0` | HTML | `q=0` means *not acceptable* | +| `text/markdown;q=0.5, */*;q=0.9` | HTML | HTML is 0.9 via the wildcard | +| `text/html,application/xhtml+xml,…,*/*;q=0.8` (a browser) | HTML | no explicit `text/markdown` | +| `text/markdownish` or malformed garbage | HTML | no substring matching; parsing never raises | + +q-values default to `1.0` and are clamped to `0..1`; malformed media-ranges are ignored and an unusable header falls back to HTML. + --- ## Deployment @@ -299,7 +329,7 @@ Ship a normal XML sitemap alongside it. (OpenAI and Anthropic expose separate to **Serve docs from generated JSON** (as pyxle.dev does): one directory handler maps a slug to a pre-built Markdown field. See [directory handler](#3-a-directory-llmspy-handler-covers-a-route-subtree) above. -**Rewrite links for portability.** Markdown that travels (pasted into a chat, saved to disk) should carry absolute links. Rewrite relative links to absolute `.md` URLs when you generate or store the Markdown, so `[Routing](../routing.md)` becomes `[Routing](https://example.com/docs/routing.md)`. +**Rewrite links for portability.** Internal links already point at `.md` renditions — [autoConvert](#autoconvert-the-lossy-fallback) and `html_to_markdown` rewrite them for you. But Markdown that travels (pasted into a chat, saved to disk) should also carry **absolute** links. Make them absolute when you generate or store the Markdown, so `[Routing](/docs/routing.md)` becomes `[Routing](https://example.com/docs/routing.md)` — the generated `/llms.txt` already does this. **Add an agent search endpoint.** Agents can search by fetching `/llms-full.txt` and scanning it, but a dedicated endpoint is nicer. A plain `pages/api/*.py` route that ranks your content and returns Markdown links works well — then point to it from `wrap_markdown`: @@ -329,7 +359,7 @@ async def endpoint(request): **Is this the same as Mintlify's `.md`/llms.txt?** Same idea, but built into the framework and applied to your *whole app*, not just a hosted docs site — and you control exactly where each page's Markdown comes from. -**Who reads all this?** Right now, the clearest win is direct: point any AI assistant — Claude, Cursor, ChatGPT — at a `.md` URL or your `llms.txt` and it gets clean, token-efficient context instead of scraped HTML. Beyond that, `.md` and [llms.txt](https://llmstxt.org/) are the conventions the AI ecosystem is standardizing on — so your app already speaks the format machine readers are moving toward, served to spec (per-page Markdown, `Accept` negotiation, discovery headers) with nothing more to do as adoption grows. +**Who reads all this?** Right now, the clearest win is direct: point any AI assistant — Claude, Cursor, ChatGPT — at a `.md` URL or your `llms.txt` and it gets clean, token-efficient context instead of scraped HTML. Beyond that, `.md` and [llms.txt](https://llmstxt.org/) are the conventions the AI ecosystem is standardizing on — so your app already speaks the format machine readers are moving toward, served to spec (per-page Markdown, RFC 9110 `Accept` negotiation, discovery headers) with nothing more to do as adoption grows. --- diff --git a/pyxle/devserver/llms.py b/pyxle/devserver/llms.py index 7c1e578..d4afc69 100644 --- a/pyxle/devserver/llms.py +++ b/pyxle/devserver/llms.py @@ -2,8 +2,9 @@ Enabled with the ``llms`` block in ``pyxle.config.json`` (see :class:`pyxle.config.LlmsConfig`). When on, the framework serves a markdown -rendition of each page at its URL with ``.md`` appended — and to requests that -send ``Accept: text/markdown`` — advertises the index via ``Link`` / +rendition of each page at its URL with ``.md`` appended — and to requests whose +``Accept`` header prefers ``text/markdown``, negotiated with RFC 9110 q-values +(see :func:`markdown_is_acceptable`) — advertises the index via ``Link`` / ``X-Llms-Txt`` discovery headers, and serves a generated ``/llms.txt`` (overridable by a static ``public/llms.txt``, which the static-asset middleware serves first). @@ -33,10 +34,12 @@ import asyncio import inspect import logging +import math from dataclasses import dataclass from html.parser import HTMLParser from pathlib import Path from typing import Any, Awaitable, Callable, Optional +from urllib.parse import urlsplit, urlunsplit from starlette.datastructures import MutableHeaders from starlette.requests import Request @@ -341,18 +344,49 @@ async def _apply_wrap_hook( ) +def _rewrite_internal_href(href: str) -> str: + """Rewrite an internal page href to its ``.md`` URL; leave everything else. + + Rewrites relative and root-relative links to extensionless page paths — + ``/about?x=1#y`` → ``/about.md?x=1#y``, ``/`` → ``/index.md`` — keeping + query strings and fragments intact. Left untouched: URLs with a scheme or + host (``https://…``, ``mailto:``, ``tel:``, protocol-relative ``//host``), + ``/api/`` routes, fragment- or query-only links, and paths whose last + segment has a file extension (assets, and links already ending in ``.md``). + """ + try: + parts = urlsplit(href) + except ValueError: + return href + if parts.scheme or parts.netloc: + return href + path = parts.path + if not path: + return href + if path == "/api" or path.startswith("/api/"): + return href + last_segment = path.rstrip("/").rsplit("/", 1)[-1] + if "." in last_segment: + return href + md_path = "/index.md" if path == "/" else path.rstrip("/") + ".md" + return urlunsplit(("", "", md_path, parts.query, parts.fragment)) + + class _MarkdownExtractor(HTMLParser): """Convert a fragment of rendered HTML into approximate markdown. Deliberately small and dependency-free: it covers the common structural tags (headings, paragraphs, lists, links, emphasis, code, blockquotes, - rules) and drops everything it doesn't understand to plain text. This backs - the opt-in ``auto_convert`` fallback only — author-provided markdown or a - handler always produces cleaner output. + rules) and drops everything it doesn't understand to plain text. With + ``rewrite_links`` internal page hrefs are rewritten to their ``.md`` + renditions (see :func:`_rewrite_internal_href`). This backs the opt-in + ``auto_convert`` fallback only — author-provided markdown or a handler + always produces cleaner output. """ - def __init__(self) -> None: + def __init__(self, *, rewrite_links: bool = False) -> None: super().__init__(convert_charrefs=True) + self._rewrite_links = rewrite_links self._out: list[str] = [] self._skip_depth = 0 self._pre_depth = 0 @@ -396,9 +430,11 @@ def handle_starttag(self, tag: str, attrs: list[tuple[str, str | None]]) -> None self._newline(2) self._emit("```\n") elif tag == "a": - href = _attr(attrs, "href") + href = _attr(attrs, "href") or "" + if href and self._rewrite_links: + href = _rewrite_internal_href(href) self._emit("[") - self._pending_prefix = href or "" + self._pending_prefix = href elif tag in ("ul", "ol"): self._list_stack.append({"ordered": tag == "ol", "n": 0}) elif tag == "li": @@ -491,9 +527,23 @@ def _attr(attrs: list[tuple[str, str | None]], name: str) -> str | None: return None -def html_to_markdown(html: str) -> str: - """Convert rendered HTML to approximate markdown (best-effort, lossy).""" - parser = _MarkdownExtractor() +def html_to_markdown(html: str, *, rewrite_links: bool = True) -> str: + """Convert rendered HTML to approximate markdown (best-effort, lossy). + + By default internal page links are rewritten to their ``.md`` renditions + (``/about`` → ``/about.md``, preserving query strings and fragments) so an + agent following links stays on the markdown channel; pass + ``rewrite_links=False`` to keep hrefs verbatim. External URLs, + ``mailto:``/``tel:``, ``/api/`` routes, asset paths with file extensions, + and links already ending in ``.md`` are never touched. + + Relative (non root-relative) hrefs are rewritten in place, so on a + directory-index page — whose markdown rendition serves from ``/deep.md`` + rather than ``/deep/`` — a link like ``child`` resolves against the + shifted base. Prefer root-relative hrefs (``/deep/child``) in pages meant + for markdown consumption. + """ + parser = _MarkdownExtractor(rewrite_links=rewrite_links) parser.feed(html) parser.close() return parser.result() @@ -563,7 +613,13 @@ class LlmsTxtContext: _render_default: Callable[[], str] def render_default(self) -> str: - """Return the framework's generated ``/llms.txt``.""" + """Return the framework's generated ``/llms.txt``. + + Performs filesystem checks and module imports. Sync ``llms_txt`` hooks + are invoked in a worker thread, so calling this from one is always + safe; an *async* hook runs on the event loop and should wrap it — + ``await asyncio.to_thread(ctx.render_default)``. + """ return self._render_default() @@ -574,17 +630,59 @@ def _page_infos(routes: RouteTable) -> tuple[LlmsPageInfo, ...]: ) -def build_llms_txt(*, routes: RouteTable, settings: Any) -> str: +def _page_serves_markdown(page: PageRoute, *, settings: Any, config: Any, debug: bool) -> bool: + """Statically walk the resolution ladder: does ``page`` have a markdown source? + + Mirrors :func:`_resolve_source_markdown` without invoking handlers: a + co-located ``.md`` file, a page-local ``to_markdown``, an ancestor + ``llms.py`` ``to_markdown``, or ``auto_convert`` means the page's ``.md`` + URL serves markdown; otherwise it just redirects to the HTML page. A + handler that exists but declines at request time counts as markdown — its + answer can't be known without running it. + """ + if getattr(config, "auto_convert", False): + return True + if colocated_markdown_path(page).is_file(): + return True + if _load_local_handler(page, debug=debug) is not None: + return True + return next(_iter_directory_handlers(page, settings, debug=debug), None) is not None + + +def build_llms_txt( + *, + routes: RouteTable, + settings: Any, + config: Any = None, + base_url: str = "", +) -> str: """Generate a spec-shaped ``/llms.txt`` index from the route table. - Produces an H1 title (the project directory name) and a ``## Pages`` list - linking each concrete page's ``.md`` rendition. Dynamic (parameterised) - routes are omitted since they have no single URL to list — apps with dynamic - content should provide a ``llms_txt`` hook or a static ``public/llms.txt``. + Produces an H1 title (the project directory name) and a ``## Pages`` list. + Each concrete page is checked against the same resolution ladder ``.md`` + requests use: pages with a markdown source (every page when + ``config.auto_convert`` is on) are linked at their ``.md`` URL, while pages + whose ``.md`` URL would just redirect are listed at their canonical HTML + URL instead — the index never advertises markdown that isn't there. + + ``base_url`` (e.g. ``"https://example.com"``, no trailing slash) makes the + links absolute; when empty, links are root-relative. Dynamic + (parameterised) routes are omitted since they have no single URL to list — + apps with dynamic content should provide a ``llms_txt`` hook or a static + ``public/llms.txt``. + + Performs filesystem checks and module imports, so call it off the event + loop — the built-in route runs it via ``asyncio.to_thread``. """ + debug = bool(getattr(settings, "debug", False)) + base = base_url.rstrip("/") lines = [f"# {_default_title(settings)}", "", "## Pages", ""] - for info in _page_infos(routes): - lines.append(f"- [{info.title}]({info.md_url})") + for page in _listable_pages(routes): + if _page_serves_markdown(page, settings=settings, config=config, debug=debug): + url = base + _md_url_for(page.path) + else: + url = base + page.path + lines.append(f"- [{_label_for(page)}]({url})") lines.append("") return "\n".join(lines) @@ -594,13 +692,137 @@ def build_llms_txt(*, routes: RouteTable, settings: Any) -> str: # --------------------------------------------------------------------------- +@dataclass(frozen=True, slots=True) +class _MediaRange: + """One parsed ``Accept`` media-range: lowercased type/subtype plus q-weight.""" + + type: str + subtype: str + q: float + + +def _split_accept_items(header: str) -> list[str]: + """Split an ``Accept`` header on commas, honouring quoted-string parameters.""" + items: list[str] = [] + current: list[str] = [] + in_quotes = False + escaped = False + for char in header: + if escaped: + current.append(char) + escaped = False + elif char == "\\" and in_quotes: + current.append(char) + escaped = True + elif char == '"': + in_quotes = not in_quotes + current.append(char) + elif char == "," and not in_quotes: + items.append("".join(current)) + current = [] + else: + current.append(char) + items.append("".join(current)) + return items + + +def _parse_q(raw: str) -> float: + """Parse a ``q`` parameter value: default 1.0 on malformed input, clamped 0..1.""" + try: + q = float(raw) + except ValueError: + return 1.0 + if math.isnan(q): + return 1.0 + return min(max(q, 0.0), 1.0) + + +def _parse_accept(header: str) -> tuple[_MediaRange, ...]: + """Parse an ``Accept`` header into media-ranges; malformed ranges are dropped.""" + ranges: list[_MediaRange] = [] + for item in _split_accept_items(header): + media, _, params = item.partition(";") + type_, sep, subtype = media.strip().lower().partition("/") + type_ = type_.strip() + subtype = subtype.strip() + if not sep or not type_ or not subtype or "/" in subtype: + continue + q = 1.0 + for param in params.split(";"): + key, eq, value = param.partition("=") + if key.strip().lower() == "q" and eq: + q = _parse_q(value.strip().strip('"')) + break # parameters after q are accept-ext (RFC 9110 §12.4.2) + ranges.append(_MediaRange(type_, subtype, q)) + return tuple(ranges) + + +def _acceptance_q( + ranges: tuple[_MediaRange, ...], type_: str, subtype: str, *, exact_only: bool = False +) -> float: + """Effective q-weight of ``type_/subtype`` under ``ranges``. + + The most specific matching range wins (exact ``type/subtype`` over + ``type/*`` over ``*/*``, per RFC 9110 §12.5.1); among equally specific + ranges the last declaration wins. Returns ``0.0`` when nothing matches — + the media type is not acceptable. With ``exact_only`` wildcard ranges are + ignored, so only an explicit ``type/subtype`` entry can make it acceptable. + """ + exact: Optional[float] = None + subtype_wildcard: Optional[float] = None + full_wildcard: Optional[float] = None + for media_range in ranges: + if media_range.type == type_ and media_range.subtype == subtype: + exact = media_range.q + elif media_range.type == type_ and media_range.subtype == "*": + subtype_wildcard = media_range.q + elif media_range.type == "*" and media_range.subtype == "*": + full_wildcard = media_range.q + if exact is not None: + return exact + if exact_only: + return 0.0 + if subtype_wildcard is not None: + return subtype_wildcard + if full_wildcard is not None: + return full_wildcard + return 0.0 + + +def markdown_is_acceptable(accept: str) -> bool: + """Decide whether ``text/markdown`` should be served for an ``Accept`` header. + + Implements RFC 9110 §12.5.1 negotiation for the one choice the feature + makes — markdown vs HTML: + + - Media-ranges match by exact ``type/subtype`` token, never by substring; + wildcards (``*/*``, ``text/*``) never select markdown, so browser Accept + headers keep getting HTML. + - ``q`` weights are honoured: default ``1.0``, clamped to ``0..1``, and + ``q=0`` means *not acceptable*. + - Markdown is served iff ``text/markdown`` is explicitly acceptable + (``q > 0``) **and** weighted at least as high as the effective weight of + ``text/html`` (which does benefit from wildcard ranges). + - Never raises: malformed ranges are ignored and an unusable header falls + back to HTML. + """ + if not accept: + return False + ranges = _parse_accept(accept) + markdown_q = _acceptance_q(ranges, "text", "markdown", exact_only=True) + if markdown_q <= 0.0: + return False + return markdown_q >= _acceptance_q(ranges, "text", "html") + + def wants_markdown(request: Request) -> bool: - """Return ``True`` when a request explicitly accepts ``text/markdown``. + """Return ``True`` when a request's ``Accept`` header prefers ``text/markdown``. Browsers never send ``text/markdown`` in ``Accept``, so this only fires for - agents that opt in — the canonical HTML URL is unchanged for humans. + agents that opt in — the canonical HTML URL is unchanged for humans. The + negotiation rules live in :func:`markdown_is_acceptable`. """ - return "text/markdown" in request.headers.get("accept", "") + return markdown_is_acceptable(request.headers.get("accept", "")) class LlmsDiscoveryMiddleware: @@ -714,20 +936,37 @@ def make_llms_txt_route(routes: RouteTable, *, settings: Any) -> Route: Resolution order: a static ``public/llms.txt`` (served by the static-asset middleware before this route ever runs) → a ``llms_txt`` hook in the root - ``pages/llms.py`` → a generated index of the app's pages. + ``pages/llms.py`` → a generated index of the app's pages. Generated links + are absolute, derived from the request's scheme and host (uvicorn's + proxy-header support keeps these correct behind a reverse proxy). """ + config = getattr(settings, "llms", None) async def handler(request: Request) -> Response: debug = bool(getattr(settings, "debug", False)) + base_url = f"{request.url.scheme}://{request.url.netloc}" + + def render_default() -> str: + return build_llms_txt( + routes=routes, settings=settings, config=config, base_url=base_url + ) + hook = _load_llms_txt_hook(settings, debug=debug) if hook is not None: ctx = LlmsTxtContext( request=request, pages=_page_infos(routes), - _render_default=lambda: build_llms_txt(routes=routes, settings=settings), + _render_default=render_default, ) try: - result = hook(ctx) + # Sync hooks run in a worker thread so a hook that calls + # ``ctx.render_default()`` (filesystem checks + module imports) + # never blocks the event loop. Async hooks own their loop + # discipline — see ``LlmsTxtContext.render_default``. + if inspect.iscoroutinefunction(hook): + result = await hook(ctx) + else: + result = await asyncio.to_thread(hook, ctx) if inspect.isawaitable(result): result = await result except Exception: @@ -736,8 +975,7 @@ async def handler(request: Request) -> Response: if isinstance(result, str): return PlainTextResponse(result, media_type=MARKDOWN_MEDIA_TYPE) return PlainTextResponse( - build_llms_txt(routes=routes, settings=settings), - media_type=MARKDOWN_MEDIA_TYPE, + await asyncio.to_thread(render_default), media_type=MARKDOWN_MEDIA_TYPE ) handler.__name__ = "llms_txt" @@ -758,6 +996,7 @@ async def handler(request: Request) -> Response: "is_enabled", "make_llms_txt_route", "make_markdown_route_handler", + "markdown_is_acceptable", "markdown_route_path", "resolve_page_markdown", "strip_md_suffix", diff --git a/tests/devserver/test_llms.py b/tests/devserver/test_llms.py index 69394e1..e75eed3 100644 --- a/tests/devserver/test_llms.py +++ b/tests/devserver/test_llms.py @@ -63,6 +63,53 @@ def test_wants_markdown(): assert llms.wants_markdown(req_none) is False +@pytest.mark.parametrize( + "accept,expected", + [ + # -- explicit opt-in ------------------------------------------------ + ("text/markdown", True), + ("text/markdown, text/html", True), # equal q -> markdown wins ties + ("text/html, text/markdown", True), + ("TEXT/MARKDOWN", True), # type/subtype match is case-insensitive + ("text/markdown;q=0.5", True), # html absent -> not acceptable + ("text/markdown;q=0.9, text/html;q=0.8", True), + ("text/markdown, */*;q=0.1", True), # html only via low-q wildcard + ("text/html;q=0, text/markdown;q=0.001", True), # html excluded + ("text/markdown;level=1;q=0.3, text/html;q=0.2", True), # extra params + ('text/markdown;note="a,b"', True), # quoted comma inside a param + ("text/markdown;q=2", True), # q clamped to 1.0 + ("text/markdown;q=abc", True), # malformed q -> default 1.0 + ("text/markdown;q=nan", True), # non-finite q -> default 1.0 + ("text/markdown;q=0.5;q=0", True), # first q ends media params (accept-ext) + # -- browsers / no explicit markdown -------------------------------- + ("", False), + ("text/html", False), + ("application/json", False), + ("text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", False), + ("*/*", False), # wildcards never select markdown + ("text/*", False), + # -- substring must NOT match (the original bug) --------------------- + ("text/markdownish", False), + ("application/text/markdown", False), + # -- q-value semantics (RFC 9110 §12.5.1) ---------------------------- + ("text/html, text/markdown;q=0", False), # q=0 means NOT acceptable + ("text/html;q=0.9, text/markdown;q=0.8", False), # HTML preferred + ("text/markdown;q=0.5, */*;q=0.9", False), # html effective 0.9 via wildcard + ("text/markdown;q=-1", False), # clamped to 0 -> excluded + ('text/markdown;note="a,b";q=0', False), # q=0 after quoted comma + # -- malformed headers never raise, fall back to HTML ---------------- + ("garbage", False), + ("text/", False), + ("/markdown", False), + ("text/a/b", False), + (";;;,,,", False), + (",", False), + ], +) +def test_markdown_is_acceptable(accept, expected): + assert llms.markdown_is_acceptable(accept) is expected + + def test_is_enabled(): assert llms.is_enabled(None) is False assert llms.is_enabled(SimpleNamespace(enabled=False)) is False @@ -90,7 +137,7 @@ def test_html_to_markdown_structure(): assert "# Title" in md assert "**world**" in md assert "*friends*" in md - assert "[here](/x)" in md + assert "[here](/x.md)" in md # internal links are rewritten by default assert "- one" in md and "- two" in md assert "1. first" in md and "2. second" in md assert "x = 1\ny = 2" in md # code preserved verbatim @@ -113,6 +160,69 @@ def test_html_to_markdown_link_without_href(): assert "](" not in md +# --------------------------------------------------------------------------- +# Internal-link rewriting (autoConvert / html_to_markdown default) +# --------------------------------------------------------------------------- + + +@pytest.mark.parametrize( + "href,expected", + [ + # rewritten: extensionless page paths get .md, query/fragment preserved + ("/about", "/about.md"), + ("/about?x=1", "/about.md?x=1"), + ("/about#y", "/about.md#y"), + ("/about?x=1#y", "/about.md?x=1#y"), + ("/", "/index.md"), + ("/docs/", "/docs.md"), # trailing slash -> canonical page path + ("/docs/v1.2/intro", "/docs/v1.2/intro.md"), # dot in a non-final segment + ("docs/intro", "docs/intro.md"), # relative links too + ("../routing", "../routing.md"), + ("/apinot", "/apinot.md"), # /api prefix must match on segment boundary + # untouched: external / protocol links + ("https://example.com/about", "https://example.com/about"), + ("http://example.com/", "http://example.com/"), + ("//cdn.example.com/lib.js", "//cdn.example.com/lib.js"), + ("mailto:hi@example.com", "mailto:hi@example.com"), + ("tel:+15550100", "tel:+15550100"), + # untouched: API routes + ("/api", "/api"), + ("/api/search?q=x", "/api/search?q=x"), + # untouched: assets and existing .md links + ("/logo.png", "/logo.png"), + ("/styles/site.css", "/styles/site.css"), + ("/about.md", "/about.md"), + # untouched: same-page and empty links + ("#section", "#section"), + ("?q=1", "?q=1"), + ("", ""), + # untouched: unparseable URL never raises + ("https://[bad", "https://[bad"), + ], +) +def test_rewrite_internal_href(href, expected): + assert llms._rewrite_internal_href(href) == expected + + +def test_html_to_markdown_rewrites_links_by_default(): + html = ( + '

About ' + 'Ext ' + 'API ' + 'Logo

' + ) + md = llms.html_to_markdown(html) + assert "[About](/about.md?x=1#y)" in md + assert "[Ext](https://ext.example/z)" in md + assert "[API](/api/search)" in md + assert "[Logo](/logo.png)" in md + + +def test_html_to_markdown_rewrite_links_opt_out(): + md = llms.html_to_markdown('About', rewrite_links=False) + assert "[About](/about)" in md + + # --------------------------------------------------------------------------- # Resolution ladder # --------------------------------------------------------------------------- @@ -204,7 +314,7 @@ async def test_auto_convert_fallback(app_tree, monkeypatch): import pyxle.ssr.view as view async def fake_render(*, request, settings, page, renderer, suppress_per_user=False): - return "

Converted

", 200 + return '

Converted

About', 200 monkeypatch.setattr(view, "render_page_body_html", fake_render) (app_tree.pages / "z.pyxl").write_text("x") @@ -212,6 +322,8 @@ async def fake_render(*, request, settings, page, renderer, suppress_per_user=Fa cfg = SimpleNamespace(enabled=True, auto_convert=True) md = await _resolve(app_tree, page, "/z.md", config=cfg) assert "# Converted" in md + # autoConvert output keeps agents on the markdown channel + assert "[About](/about.md)" in md async def test_no_source_returns_none(app_tree): @@ -234,21 +346,76 @@ async def test_handler_must_return_str_or_none(app_tree): # --------------------------------------------------------------------------- -def _routes(*paths): - return SimpleNamespace(pages=[SimpleNamespace(path=p) for p in paths]) +def _routes_in(app_tree, *specs): + """Route table of full page descriptors; each spec is ``(rel_source, path)``.""" + return SimpleNamespace(pages=[_page_in(app_tree, rel, path) for rel, path in specs]) -def test_build_llms_txt(tmp_path): - settings = SimpleNamespace(project_root=tmp_path / "acme-docs", pages_dir=tmp_path) - routes = _routes("/", "/about", "/docs/{slug:path}", "/benchmarks") - txt = llms.build_llms_txt(routes=routes, settings=settings) - assert txt.startswith("# Acme Docs") - assert "- [Home](/index.md)" in txt - assert "- [About](/about.md)" in txt - assert "- [Benchmarks](/benchmarks.md)" in txt +def test_build_llms_txt_links_md_only_when_it_resolves(app_tree): + # about has a co-located .md -> .md link; bare has no source -> HTML link + (app_tree.pages / "about.pyxl").write_text("x") + (app_tree.pages / "about.md").write_text("# About\n") + (app_tree.pages / "bare.pyxl").write_text("x") + routes = _routes_in( + app_tree, + ("index.pyxl", "/"), + ("about.pyxl", "/about"), + ("bare.pyxl", "/bare"), + ("docs/[[...slug]].pyxl", "/docs/{slug:path}"), + ) + txt = llms.build_llms_txt( + routes=routes, + settings=app_tree.settings, + config=app_tree.settings.llms, + base_url="https://example.com", + ) + assert txt.startswith("# ") + assert "## Pages" in txt + assert "- [About](https://example.com/about.md)" in txt + assert "- [Bare](https://example.com/bare)" in txt # never a dead-end .md link + assert "- [Home](https://example.com/)" in txt # no source -> HTML URL assert "slug" not in txt # dynamic route omitted +def test_build_llms_txt_handlers_count_as_markdown(app_tree): + # a page-local to_markdown and a directory llms.py both make .md real + (app_tree.pages / "p.pyxl").write_text("x") + (app_tree.pages / "p.py").write_text("def to_markdown(ctx):\n return '# P'\n") + (app_tree.pages / "docs" / "intro.pyxl").write_text("x") + (app_tree.pages / "docs" / "llms.py").write_text( + "def to_markdown(ctx):\n return '# D'\n" + ) + routes = _routes_in(app_tree, ("p.pyxl", "/p"), ("docs/intro.pyxl", "/docs/intro")) + txt = llms.build_llms_txt( + routes=routes, + settings=app_tree.settings, + config=app_tree.settings.llms, + base_url="https://example.com", + ) + assert "- [P](https://example.com/p.md)" in txt + assert "- [Intro](https://example.com/docs/intro.md)" in txt + + +def test_build_llms_txt_auto_convert_links_every_page(app_tree): + (app_tree.pages / "bare.pyxl").write_text("x") + routes = _routes_in(app_tree, ("bare.pyxl", "/bare")) + cfg = SimpleNamespace(enabled=True, auto_convert=True) + txt = llms.build_llms_txt( + routes=routes, settings=app_tree.settings, config=cfg, base_url="https://example.com" + ) + assert "- [Bare](https://example.com/bare.md)" in txt + + +def test_build_llms_txt_empty_base_url_is_relative(app_tree): + (app_tree.pages / "about.pyxl").write_text("x") + (app_tree.pages / "about.md").write_text("# About\n") + routes = _routes_in(app_tree, ("about.pyxl", "/about")) + txt = llms.build_llms_txt( + routes=routes, settings=app_tree.settings, config=app_tree.settings.llms + ) + assert "- [About](/about.md)" in txt + + # --------------------------------------------------------------------------- # Route + middleware integration (via TestClient) # --------------------------------------------------------------------------- @@ -301,16 +468,21 @@ def test_markdown_route_dynamic_slug(app_tree): def test_llms_txt_route_default_and_hook(app_tree): - routes = _routes("/", "/about") - # default generated index + (app_tree.pages / "about.pyxl").write_text("x") + (app_tree.pages / "about.md").write_text("# About\n") + (app_tree.pages / "bare.pyxl").write_text("x") + routes = _routes_in(app_tree, ("about.pyxl", "/about"), ("bare.pyxl", "/bare")) + # default generated index: absolute URLs from the request's scheme + host, + # .md only for pages whose markdown actually resolves default_route = llms.make_llms_txt_route(routes, settings=app_tree.settings) client = TestClient(Starlette(routes=[default_route])) resp = client.get("/llms.txt") assert resp.status_code == 200 assert "## Pages" in resp.text - assert "- [About](/about.md)" in resp.text + assert "- [About](http://testserver/about.md)" in resp.text + assert "- [Bare](http://testserver/bare)" in resp.text - # hook override in root pages/llms.py + # hook override in root pages/llms.py; render_default() keeps the same links (app_tree.pages / "llms.py").write_text( "def llms_txt(ctx):\n return '# Custom\\n' + ctx.render_default()\n" ) @@ -319,6 +491,7 @@ def test_llms_txt_route_default_and_hook(app_tree): resp2 = client2.get("/llms.txt") assert resp2.text.startswith("# Custom\n") assert "## Pages" in resp2.text + assert "- [About](http://testserver/about.md)" in resp2.text def test_discovery_middleware_adds_headers(): @@ -454,15 +627,21 @@ async def test_local_handler_import_failure_falls_through(app_tree): def test_llms_txt_hook_returning_none_falls_back(app_tree): + (app_tree.pages / "about.pyxl").write_text("x") (app_tree.pages / "llms.py").write_text("def llms_txt(ctx):\n return None\n") - route = llms.make_llms_txt_route(_routes("/", "/about"), settings=app_tree.settings) + route = llms.make_llms_txt_route( + _routes_in(app_tree, ("about.pyxl", "/about")), settings=app_tree.settings + ) resp = TestClient(Starlette(routes=[route])).get("/llms.txt") assert resp.status_code == 200 and "## Pages" in resp.text def test_llms_txt_hook_error_falls_back(app_tree): + (app_tree.pages / "about.pyxl").write_text("x") (app_tree.pages / "llms.py").write_text("def llms_txt(ctx):\n raise RuntimeError('x')\n") - route = llms.make_llms_txt_route(_routes("/", "/about"), settings=app_tree.settings) + route = llms.make_llms_txt_route( + _routes_in(app_tree, ("about.pyxl", "/about")), settings=app_tree.settings + ) resp = TestClient(Starlette(routes=[route])).get("/llms.txt") assert resp.status_code == 200 and "## Pages" in resp.text @@ -490,6 +669,38 @@ def test_html_to_markdown_blockquote_and_nested_list(): # --------------------------------------------------------------------------- +def test_markdown_route_auto_convert_rewrites_links_and_wrap_sees_them( + app_tree, monkeypatch +): + import pyxle.ssr.view as view + + async def fake_render(*, request, settings, page, renderer, suppress_per_user=False): + html = ( + '

See about, ' + 'ext and ' + 'api.

' + ) + return html, 200 + + monkeypatch.setattr(view, "render_page_body_html", fake_render) + (app_tree.pages / "z.pyxl").write_text("x") + (app_tree.pages / "llms.py").write_text( + "def wrap_markdown(ctx, md):\n return 'HDR\\n' + md\n" + ) + routes = SimpleNamespace(pages=[_page_in(app_tree, "z.pyxl", "/z")]) + cfg = SimpleNamespace(enabled=True, auto_convert=True) + md_routes = llms.build_markdown_routes( + routes, settings=app_tree.settings, renderer=None, config=cfg + ) + resp = TestClient(Starlette(routes=md_routes)).get("/z.md") + assert resp.status_code == 200 + # wrap_markdown received the already-rewritten markdown + assert resp.text.startswith("HDR\n") + assert "[about](/about.md?x=1#y)" in resp.text + assert "[ext](https://ext.example/z)" in resp.text + assert "[api](/api/go)" in resp.text + + async def test_wrap_markdown_hook_frames_output(app_tree): (app_tree.pages / "about.pyxl").write_text("x") (app_tree.pages / "about.md").write_text("# About\n") @@ -556,3 +767,28 @@ async def test_run_loader_no_loader_returns_empty(app_tree): ) page = _page_in(app_tree, "q.pyxl", "/q", has_loader=False, loader_name=None) assert await _resolve(app_tree, page, "/q.md") == "EMPTY" + + +def test_llms_txt_sync_hook_runs_off_event_loop(app_tree): + """A sync ``llms_txt`` hook runs in a worker thread (no running event + loop), so a hook that calls ``ctx.render_default()`` — filesystem checks + plus module imports — can never block the loop.""" + (app_tree.pages / "about.pyxl").write_text("x") + (app_tree.pages / "about.md").write_text("# About\n") + routes = _routes_in(app_tree, ("about.pyxl", "/about")) + (app_tree.pages / "llms.py").write_text( + "import asyncio\n" + "def llms_txt(ctx):\n" + " try:\n" + " asyncio.get_running_loop()\n" + " marker = 'ON_LOOP'\n" + " except RuntimeError:\n" + " marker = 'OFF_LOOP'\n" + " return f'# {marker}\\n' + ctx.render_default()\n" + ) + route = llms.make_llms_txt_route(routes, settings=app_tree.settings) + client = TestClient(Starlette(routes=[route])) + resp = client.get("/llms.txt") + assert resp.status_code == 200 + assert resp.text.startswith("# OFF_LOOP\n") + assert "- [About](http://testserver/about.md)" in resp.text