ludic

repository·main·Indexed 21 days ago

https://github.com/getludic/ludic

A lightweight, type-safe Python framework for building dynamic HTML pages using a component-based approach. It leverages Python 3.14's t-strings for secure templating and integrates with htmx for web development without heavy JavaScript. The framework includes LudicApp (a Starlette-based wrapper), type-guided HTML enforcement, and specialized attribute management via Attrs, HtmlAttrs, and HtmxAttrs.

Tokens
13.1K
Snippets
47
Records
54
Agent score
75%

What's inside ludic

  1. Prevent Host Header Poisoning in URLs

    main

    Using Request.url_for(...) derives the scheme and host from the incoming Host header. If your application does not validate this header, attackers can poison absolute URLs (including hx-get, hx-post, and redirects) to point to malicious domains.

    Best Practices:

    1. Use Relative Paths: Prefer request.url_path_for(...) or request.url_for(...).path for in-app links and htmx attributes. These cannot be poisoned.
    2. Use Trusted Hosts: Use starlette.middleware.trustedhost.TrustedHostMiddleware to reject requests with forged Host headers.
    3. Use Trusted Proxies: If behind a CDN/Proxy, use ProxyHeadersMiddleware to ensure X-Forwarded-* headers are only honored from trusted IP ranges.
    4. Avoid Request-derived Absolute URLs: For outbound links (emails, OAuth), use a configuration-controlled BASE_URL instead of deriving it from the request.
    # GOOD: Relative path, cannot be poisoned
    a("Profile", href=request.url_path_for("profile", user_id=user.id))
    
    button(
        "Refresh",
        hx_get=request.url_path_for("items_partial"),
        hx_target="#items",
    )
    
    # BAD: Host comes from the request and can be poisoned
    a("Profile", href=str(request.url_for("profile", user_id=user.id)))
  2. Understand the Ludic component mental model

    main

    Ludic uses a three-layer architecture for building HTML pages:

    1. HTML elements (ludic.html): Low-level, typed primitives like div, a, table, and form. Each has a signature defining allowed children and attributes.
    2. Components (ludic.components): Reusable units created by subclassing Component or ComponentStrict. They implement a render() method that returns an element tree and can carry CSS via classes and styles.
    3. Catalog (ludic.catalog): High-level, opinionated widgets (e.g., PageLayout, Form, Table) built on top of core elements. Use these instead of raw divs to ensure sensible defaults and idiomatic layouts.

    The rendering pipeline is triggered by .to_html(), which expands t-strings, escapes untrusted text, and formats attributes.

  3. How Ludic components and type-safety work

    main

    Ludic uses Python's typing system to enforce valid HTML structures. This prevents common errors like adding children to elements that cannot have them (e.g., <br>) or using invalid attributes on specific tags.

    Key Concepts:

    • Type-Guided HTML: The framework enforces rules such as ensuring an html() call has a <head> as its first child, or that <a> tags receive valid attributes like href while preventing unknown ones.
    • Composable Components: You can define custom, reusable components by subclassing Component. These components can have dynamic properties and are fully type-checked.
    # Example of type-enforced HTML structure
    br("Hello")        # type error: <br> cannot have children
    br()                 # ok
    
    html(body(...))      # type error: first child must be <head>
    html(head(...), body(...)) # ok
    
    div("Test", href="test") # type error: unknown attribute
    a("Test", href="...")    # ok
  4. Install Ludic

    main

    You can install Ludic with its full set of dependencies using pip. For a quick start, you can also use a cookiecutter template via uvx to scaffold a new project.

    Requirements:

    • Python 3.14+ (required for Ludic 1.x and t-string support).
    • For Python 3.12 or 3.13, use Ludic 0.5.x which utilizes f-strings instead.
    pip install "ludic[full]"
    
    # Or use the cookiecutter template with uv
    uvx cookiecutter gh:getludic/template
  5. Configure a hardened LudicApp setup

    main

    To protect against host header poisoning when running behind a proxy, configure middleware in a specific order: first ProxyHeadersMiddleware to process trusted proxy headers, then TrustedHostMiddleware to validate the resulting host.

    from starlette.middleware import Middleware
    from starlette.middleware.trustedhost import TrustedHostMiddleware
    from uvicorn.middleware.proxy_headers import ProxyHeadersMiddleware
    from ludic.web import LudicApp
    
    middleware = [
        # 1. Process proxy headers first
        Middleware(ProxyHeadersMiddleware, trusted_hosts=["127.0.0.1"]),
        # 2. Validate the host against an allowlist
        Middleware(TrustedHostMiddleware, allowed_hosts=["example.com", "*.example.com"]),
    ]
    
    app = LudicApp(middleware=middleware)
  6. Integrate Ludic with FastAPI

    main

    To use Ludic with FastAPI, install the extra: pip install "ludic[fastapi]".

    You can make FastAPI automatically render Ludic elements as HTML by setting the app.router.route_class to LudicRoute.

    from fastapi import FastAPI
    from ludic.contrib.fastapi import LudicRoute
    
    app = FastAPI()
    app.router.route_class = LudicRoute  # makes returns of Ludic elements render as HTML
    
    @app.get("/", response_class=LudicResponse)
    async def home() -> p:
        return p("Hello from FastAPI + Ludic")
  7. Handle the trusted/untrusted content boundary with Safe

    main

    Ludic escapes all content by default to prevent XSS. If you have content that is already verified as safe HTML, you must explicitly wrap it in Safe to prevent it from being escaped.

    Warning: Never wrap user-provided input in Safe. Only use it for content you control or have sanitized.

    There is also JavaScript(Safe) for inline JavaScript bodies.

    from ludic.types import Safe
    
    # Escaped (Safe default)
    div("Hello <b>World</b>").to_html()
    # → '<div>Hello &lt;b>World&lt;/b></div>'
    
    # Raw (You take responsibility)
    div(Safe("Hello <b>World</b>")).to_html()
    # → '<div>Hello <b>World</b></div>'
  8. Run a Ludic example using Uvicorn

    main

    You can run any example by using uvicorn. Note that you must execute this command from the root of the repository. Replace <name_of_example> with the specific name of the example directory/module you wish to run.

    Once running, the application will be available at http://127.0.0.1:8000.

    uvicorn examples.<name_of_example>:app --reload
  9. Migrate from Ludic v0.5 to v1.0

    main

    Ludic 1.0 introduces a major change by replacing f-strings with t-strings (template strings) for HTML templating. This change requires Python 3.14+.

    VersionPython RequirementTemplating Method
    0.5.x3.12, 3.13f-strings
    1.0.x3.14+t-strings

    Migration Task: Replace f" with t" when mixing HTML elements with text.

    # v0.5 (f-strings)
    div(f"Hello {b('World')}")
    
    # v1.0 (t-strings)
    div(t"Hello {b('World')}")
  10. Create custom components with Attrs and Component

    main

    To build modular components, define a class that inherits from Attrs for your component's properties and Component for the component logic. Use the @override decorator on the render method to define how the component transforms into HTML elements.

    In Ludic 1.0, use t-strings (t"...") when mixing HTML elements with text inside your render methods for better performance and security.

    from typing import override
    from ludic import Attrs, Component
    from ludic.html import a
    
    class LinkAttrs(Attrs):
        to: str
    
    class Link(Component[str, LinkAttrs]):
        classes = ["link"]
    
        @override
        def render(self) -> a:
            return a(
                *self.children,
                href=self.attrs["to"],
                style={"color": self.theme.colors.primary},
            )
    
    # Usage
    link = Link("Hello, World!", to="/home")
  11. Integrate Ludic with Django

    main

    To use Ludic with Django, install the extra: pip install "ludic[django]".

    Integration is handled via LudicView in your urls.py. Note that Django uses its own request/response cycle, so host-header protection should be managed via Django's native ALLOWED_HOSTS setting and USE_X_FORWARDED_HOST configuration.

    # urls.py
    from ludic.contrib.django import LudicView
    from .views import HomeView
    
    urlpatterns = [path("", LudicView.as_view(component=HomeView))]