Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,7 @@ from the very first prompt — no priming, no setup.
- **Vite HMR** — instant hot reload in development
- **Tailwind** — preconfigured out of the box
- **Production build** — `pyxle build` + `pyxle serve`; deploy anywhere Python runs
- **AI accessibility** — serve any page as clean Markdown (append `.md`, or `Accept: text/markdown`) plus an `llms.txt` index, so AI agents read your app as text — one flag, agent-friendly out of the box
- **Editor tooling** — LSP, linter, and a [VS Code extension](https://marketplace.visualstudio.com/items?itemName=pyxle.pyxle-language-tools)

## Status
Expand Down
4 changes: 4 additions & 0 deletions docs/changelog.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,10 @@

Release notes for Pyxle. While we're in beta (`0.x`), minor versions may include breaking changes — those are called out explicitly here. To upgrade, run `pip install --upgrade pyxle-framework`.

## 0.6.0 — 2026-07-01

- **AI accessibility — serve your app as markdown, plus `llms.txt`.** A new opt-in `llms` config block makes every Pyxle app legible to AI assistants and coding agents. Enable it (`"llms": true`) and the framework serves a clean **markdown** rendition of each page at its URL with `.md` appended (`/docs/routing.md`), returns markdown from the same URL to requests that send `Accept: text/markdown` (browsers are unaffected), serves an **`/llms.txt`** index, and advertises it with `Link`/`X-Llms-Txt` discovery headers. A page's markdown is resolved from your project — a co-located `<page>.md` file, a `to_markdown` handler in the page's server module, or a `to_markdown` in the nearest ancestor `llms.py` (which scopes to a whole route subtree; `pages/llms.py` is app-wide) — with an opt-in `autoConvert` HTML→markdown fallback and a redirect-to-the-page fallback when nothing resolves. `/llms.txt` comes from a static `public/llms.txt`, a `llms_txt` hook in `pages/llms.py`, or a generated index, and a `wrap_markdown` hook in `pages/llms.py` can frame every `.md` response with a header/footer (e.g. agent navigation hints). Off by default; adds nothing to the page hot path. See the [AI accessibility guide](guides/llms.md) and [Configuration → AI accessibility](reference/configuration.md#ai-accessibility).

## 0.5.0 — 2026-06-18

The depth release: caching/SSG/ISR, streaming SSR, realtime (WebSockets + a
Expand Down
14 changes: 14 additions & 0 deletions docs/guides/for-ai-agents.md
Original file line number Diff line number Diff line change
Expand Up @@ -633,6 +633,20 @@ instead of scanning the full source tree.

---

## Serve your whole app as Markdown

There's a second half to "agent-first": not just *writing* code an agent can reason about, but *serving* content an agent can read. Pyxle ships that too. Enable the [`llms`](llms.md) feature and every page of your app is available as clean Markdown — append `.md` to any URL, or send `Accept: text/markdown` — plus an [`/llms.txt`](https://llmstxt.org/) index and discovery headers:

```json
{ "llms": true }
```

Now a developer can paste `yourapp.com/docs/anything.md` straight into Claude, Cursor, or ChatGPT and get clean, token-efficient context instead of HTML. You decide where each page's Markdown comes from — a co-located `.md` file, a `to_markdown` handler, or a directory-wide `llms.py` — and a `wrap_markdown` hook can frame every page with instructions for the agent reading it. This very handbook is served that way: append `.md` to this page's URL to see it.

See the [AI accessibility guide](llms.md) for the full model.

---

## Next steps

- **New to Pyxle?** Start with the [quick start](../getting-started/quick-start.md).
Expand Down
341 changes: 341 additions & 0 deletions docs/guides/llms.md

Large diffs are not rendered by default.

27 changes: 27 additions & 0 deletions docs/reference/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,10 @@ Pyxle is configured via `pyxle.config.json` in the project root. All fields are
"otelServiceName": "pyxle-app",
"otelSampleRatio": 0.05
},
"llms": {
"enabled": false,
"autoConvert": false
},
"plugins": []
}
```
Expand Down Expand Up @@ -320,6 +324,29 @@ Request correlation IDs and timing. Both are **on by default** -- generating an

Shorthand to disable request-id and timing: `"observability": false`. The page-cache hit-ratio metric requires the [server-side page cache](../guides/caching.md) (active under `pyxle serve`). **Multi-worker note:** under `pyxle serve --workers N` each worker process exposes its own metrics (with a `worker` label), so aggregate across workers at the scraper.

## AI accessibility

Serve a clean **markdown** rendition of every page — at its URL with `.md` appended, and to requests that send `Accept: text/markdown` — plus an `/llms.txt` index, so AI assistants and coding agents can read your app as text instead of parsing HTML. **Off by default.** See the [AI accessibility guide](../guides/llms.md) for the full model.

```json
{
"llms": {
"enabled": true,
"autoConvert": false
}
}
```

| Key | Type | Default | Description |
|-----|------|---------|-------------|
| `llms` | `object` \| `boolean` | `{}` | AI-accessibility settings. `true` enables with defaults; an object enables unless `enabled: false`. |
| `llms.enabled` | `boolean` | `false` | Turn the feature on: register `<page>.md` routes, negotiate `Accept: text/markdown`, serve `/llms.txt`, and add `Link`/`X-Llms-Txt` discovery headers. |
| `llms.autoConvert` | `boolean` | `false` | Last-resort fallback: when a page has no markdown source, convert its rendered HTML to markdown. **Off by default** — the conversion is lossy; prefer author-provided markdown. |

**Where the markdown comes from** is *not* config — it lives in your project, resolved in this order (first hit wins): a co-located `<page>.md` file → a `to_markdown` handler in the page's server module → a `to_markdown` in the nearest ancestor `llms.py` (covers a route subtree; `pages/llms.py` is app-wide) → `autoConvert` (if on) → otherwise the `.md` URL redirects to the page. A root `pages/llms.py` may also define `wrap_markdown(ctx, markdown)` to frame every `.md` response with a header/footer. Likewise `/llms.txt` comes from a static `public/llms.txt`, else a `llms_txt` function in the root `pages/llms.py`, else a generated index. See the [guide](../guides/llms.md).

> **Deploying handlers.** Co-located `.md` files and `llms.py` handlers are part of your source, so they must be present alongside `pages/` when you `pyxle serve`. (A page's own `to_markdown`, compiled into the build, works regardless.)

## Plugins

### `plugins`
Expand Down
43 changes: 43 additions & 0 deletions docs/reference/runtime-api.md
Original file line number Diff line number Diff line change
Expand Up @@ -301,6 +301,49 @@ Whether the upgrade's `Origin` is permitted. CSRF doesn't apply to a WebSocket,
so checking the origin is the equivalent guard. An empty `allowed_origins`
allows all; a missing `Origin` header (same-origin / non-browser) is allowed.

## AI accessibility hooks

Conventions Pyxle recognizes when the [`llms`](configuration.md#ai-accessibility) feature is enabled, so your app can serve a Markdown rendition of each page. None are imported — they're functions you define by name in your `.pyxl` server sections or in `llms.py` files. See the [AI accessibility guide](../guides/llms.md) for the full model.

### `to_markdown(ctx)`

Returns the Markdown for a page. Define it in a page's server section (scopes to that page) or in an `llms.py` file (scopes to that directory's subtree; nearest ancestor wins). Sync or async.

```python
def to_markdown(ctx): # ctx: MarkdownContext
return f"# {title}\n\n{body}\n" # str -> served
# return None # -> decline, fall through to the next source
```

### `llms_txt(ctx)`

Generates `/llms.txt`. Define it in the **root** `pages/llms.py`. Sync or async. Return a string, or `None` to use the generated default. `ctx` is an `LlmsTxtContext`.

### `wrap_markdown(ctx, markdown)`

Frames every resolved `.md` response with a header/footer. Define it in the **root** `pages/llms.py`. Sync or async. Return the wrapped string, or `None` to leave `markdown` unchanged. `ctx` is a `MarkdownContext`.

### Context objects

**`MarkdownContext`** — passed to `to_markdown` and `wrap_markdown`:

| Member | Type | Description |
|--------|------|-------------|
| `ctx.request` | `starlette.requests.Request` | The request. `ctx.request.path_params` holds route params (e.g. `slug`). |
| `ctx.path` | `str` | Canonical page path, always without `.md` (e.g. `/docs/routing`, `/`). Prefer this over `request.url.path`. |
| `await ctx.run_loader()` | `Any` | Runs only the page's `@server` loader and returns its data (no render). `{}` if the page has no loader. |
| `await ctx.render_html()` | `str` | Renders the page (loader + SSR) and returns the body HTML. Lazy; only call if you need it. |

**`LlmsTxtContext`** — passed to `llms_txt`:

| Member | Type | Description |
|--------|------|-------------|
| `ctx.request` | `Request` | The request. |
| `ctx.pages` | `tuple[LlmsPageInfo, ...]` | The app's concrete pages. |
| `ctx.render_default()` | `str` | The framework's generated `/llms.txt`. |

**`LlmsPageInfo`** — each entry in `ctx.pages`: `path` (e.g. `/about`), `md_url` (`/about.md`), `title` (humanized label).

## Document `<head>` elements

Pyxle offers two ways to contribute elements to the document `<head>`: the `<Head>` component (**recommended**) and the `HEAD` Python variable (lower-level alternative). Both are merged with automatic deduplication.
Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ build-backend = "hatchling.build"

[project]
name = "pyxle-framework"
version = "0.5.0"
version = "0.6.0"
description = "Python-first full-stack framework with an intelligent CLI."
readme = "README.md"
authors = [
Expand Down
80 changes: 80 additions & 0 deletions pyxle/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -162,6 +162,39 @@ def enabled(self) -> bool:
return self.request_id or self.timing or self.metrics_endpoint or self.access_log


@dataclass(frozen=True, slots=True)
class LlmsConfig:
"""AI-accessibility settings: per-page markdown and an ``llms.txt`` index.

When ``enabled``, the framework serves a markdown rendition of each page at
the page's URL with ``.md`` appended (and when a request sends
``Accept: text/markdown``), advertises the index via ``Link``/``X-Llms-Txt``
discovery headers, and serves ``/llms.txt``. Everything here is **off by
default** — an opt-in feature for making an app legible to AI assistants and
coding agents.

A page's markdown is resolved in this order, first hit wins:

1. A co-located ``<page>.md`` file next to the ``.pyxl`` source.
2. A ``to_markdown`` handler in the page's own server module (a function
``fn(ctx) -> str | None``, sync or async).
3. A ``to_markdown`` in the nearest ancestor ``llms.py`` — a per-directory
module covering a whole route subtree (closest ancestor wins, like
``layout.pyxl``); ``pages/llms.py`` is the app-wide handler.
4. Only if ``auto_convert`` is on: a best-effort HTML→markdown conversion of
the rendered page.
5. If none apply: the ``.md`` URL redirects to the page itself.

``/llms.txt`` is served from a static ``public/llms.txt`` if present, else
from a ``llms_txt`` function in the root ``pages/llms.py``, else from a
generated index of the app's pages. ``auto_convert`` defaults off because the
conversion is lossy — prefer author-provided markdown or a handler.
"""

enabled: bool = False
auto_convert: bool = False


@dataclass(frozen=True, slots=True)
class PyxleConfig:
"""Resolved configuration values for a Pyxle project."""
Expand All @@ -186,6 +219,7 @@ class PyxleConfig:
navigation: NavigationConfig = NavigationConfig()
rate_limit: RateLimitConfig = RateLimitConfig()
observability: ObservabilityConfig = ObservabilityConfig()
llms: LlmsConfig = LlmsConfig()
# Plugin entries as the raw payload from ``pyxle.config.json`` —
# either a bare string (``"pyxle-auth"``) or an object
# (``{"name": "pyxle-auth", "settings": {...}}``). Resolved into
Expand Down Expand Up @@ -216,6 +250,7 @@ def to_devserver_kwargs(self) -> Dict[str, Any]:
"navigation": self.navigation,
"rate_limit": self.rate_limit,
"observability": self.observability,
"llms": self.llms,
"plugins": self.plugins,
}

Expand Down Expand Up @@ -324,6 +359,7 @@ def _parse_config_dict(data: Dict[str, Any], *, source: Path) -> PyxleConfig:
"navigation",
"rateLimit",
"observability",
"llms",
"plugins",
}
unknown_keys = set(data) - allowed_top_keys
Expand Down Expand Up @@ -359,6 +395,7 @@ def _parse_config_dict(data: Dict[str, Any], *, source: Path) -> PyxleConfig:
navigation_config = _parse_navigation_block(data.get("navigation"), source=source)
rate_limit_config = _parse_rate_limit_block(data.get("rateLimit"), source=source)
observability_config = _parse_observability_block(data.get("observability"), source=source)
llms_config = _parse_llms_block(data.get("llms"), source=source)
plugins = _parse_plugins_block(data.get("plugins"), source=source)

return PyxleConfig(
Expand All @@ -382,6 +419,7 @@ def _parse_config_dict(data: Dict[str, Any], *, source: Path) -> PyxleConfig:
navigation=navigation_config,
rate_limit=rate_limit_config,
observability=observability_config,
llms=llms_config,
plugins=plugins,
)

Expand Down Expand Up @@ -424,6 +462,48 @@ def _parse_plugins_block(value: Any, *, source: Path) -> tuple[Any, ...]:
return tuple(entries)


def _parse_llms_block(value: Any, *, source: Path) -> LlmsConfig:
"""Parse the ``llms`` block — AI/markdown accessibility settings.

Accepts a boolean shorthand (``"llms": true`` enables the feature with
defaults) or an object with ``enabled`` and ``autoConvert`` keys. When the
object form is used, the feature is enabled unless ``enabled: false`` is set
explicitly. Markdown handlers and the ``llms.txt`` index live in ``llms.py``
files, not in config.
"""
if value is None:
return LlmsConfig()
if isinstance(value, bool):
return LlmsConfig(enabled=value)
if not isinstance(value, Mapping):
raise ConfigError(
f"Invalid value for 'llms' in '{source}': expected an object or boolean."
)

_reject_unknown_keys(
value,
allowed={"enabled", "autoConvert"},
block="llms",
source=source,
)

enabled = value.get("enabled", True)
if not isinstance(enabled, bool):
raise ConfigError(
f"Invalid 'llms.enabled' in '{source}': expected boolean, "
f"got {type(enabled).__name__}."
)

auto_convert = value.get("autoConvert", False)
if not isinstance(auto_convert, bool):
raise ConfigError(
f"Invalid 'llms.autoConvert' in '{source}': expected boolean, "
f"got {type(auto_convert).__name__}."
)

return LlmsConfig(enabled=enabled, auto_convert=auto_convert)


def _parse_styling_block(value: Any, *, source: Path) -> tuple[tuple[str, ...], tuple[str, ...]]:
if value is None:
return ((), ())
Expand Down
Loading
Loading