Customizing FastHTML Headers From Notebook Contents

by Audrey M. Roy Greenfeld | Wed, Jan 22, 2025

Can we customize a FastHTML app to set different headers when rendering notebooks with nb2fasthtml, based on what the notebook actually needs for its headers?


from fasthtml.common import *
from monsterui.all import *
from nb2fasthtml.core import *
from pathlib import Path
import json, yaml
from execnb.nbio import *
from execnb.shell import render_outputs
from typing import Callable
from functools import partial
from IPython.display import display,HTML
tags = ["Python", "Markdown"]
hdrs = (MarkdownJS(), HighlightJS(langs=['python', 'javascript', 'html', 'css']))

Here I define what is essentially blog post frontmatter, but in multiple cells that are Markdown or Python depending on their purpose. You'll see more about this frontmatter later in the My Multi-Cell Frontmatter Format section.

Warning

This notebook doesn't render well on https://audrey.feldroy.com/nbs/2025-01-22-Customizing-FastHTML-Headers-Per-Route-From-Notebook-Contents and I haven't figured out why.

To see it a little better, download https://github.com/audreyfeldroy/arg-blog-fasthtml/blob/main/nbs/2025-01-22-Customizing-FastHTML-Headers-Per-Route-From-Notebook-Contents.ipynb


Better yet, just look at my arg-blog-fasthtml main.py's notebook route for the TL;DR on what I did.

Background

I like to experiment deeply with different FastHTML headers in different daily notebooks. My use cases include:

I definitely don't want all my notebooks rendered on this site with the same FastHTML headers! That's how audrey.feldroy.com started off, but today as a result of this exploration, I now have different headers depending on what the notebook needs.

Setup

app, rt = fast_app(hdrs=hdrs)

Render NB

This is render_nb from nb2fasthtml.core:

def render_nb(fpath, # Path to Jupyter Notebook
              wrapper=Main, #Wraps entire rendered NB, default is for pico
              cls='container', # cls to be passed to wrapper, default is for pico
              md_cell_wrapper=Div, # Wraps markdown cell
              md_fn=render_md, # md -> rendered html
              code_cell_wrapper=Card, # Wraps Source Code (body) + Outputs (footer)
              cd_fn=render_code_source, # code cell -> code source rendered html
              out_fn=render_code_output, # code cell -> code output rendered html
              get_fm=get_frontmatter_md, # How to read frontmatter cell
              fm_fn:None|Callable=render_frontmatter, # Frontmatter -> FT components
              **kwargs # Passed to wrapper
             ): 
    nb = read_nb(fpath)
    res, content_start_idx = [], 0
    if fm_fn: 
        content_start_idx = 1
        fm = get_fm(nb.cells[0])
        res.append(fm_fn(fm))
    for cell in nb.cells[content_start_idx:]:
        if   cell['cell_type']=='code'    : res.append(code_cell_wrapper(cd_fn(cell), out_fn(cell)))
        elif cell['cell_type']=='markdown': res.append(md_cell_wrapper(md_fn(cell.source)))
    return wrapper(cls=cls, **kwargs)(*res)

Usage of render_nb in my Blog

Currently in the main.py for https://audrey.feldroy.com, I call it from my notebooks detail handler:

@rt('/nbs/{name}')
def notebook(name: str):
    nb = Path(f'nbs/{name}.ipynb')
    return (
        Title(get_title(nb)),
        Style(':root {font-family: system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", sans-serif; color-scheme: light dark;} body {background-color: light-dark(#ffffff, #1a1a1a); color: light-dark(#1a1a1a, #ffffff);} p {line-height: 1.5;}'),
        render_nb(nb, wrapper=Div),
    )

Getting Frontmatter

Here I study all the code in get_frontmatter, bringing in everything it depends on.

_RE_FM_BASE=r'''^---\s*
(.*?\S+.*?)
---\s*'''
_re_fm_nb = re.compile(_RE_FM_BASE+'$', flags=re.DOTALL)

I asked AI to explain this regex:

This regex pattern is designed to match YAML frontmatter at the start of a document:

  1. ^---\s* - Matches start of text (^), three dashes, and optional whitespace
  2. (.*?\S+.*?) - Captures content between dashes:
    • .*? - Any chars (non-greedy)
    • \S+ - At least one non-whitespace char
    • .*? - Any chars (non-greedy)
  3. ---\s* - Matches three dashes and optional whitespace
  4. $ - End of text (with re.DOTALL flag allowing . to match newlines)

This matches standard YAML frontmatter format like:

---
title: My Post
date: 2024-01-22
---
def _fm2dict(s:str, nb=True):
    "Load YAML frontmatter into a `dict`"
    re_fm = _re_fm_nb if nb else _re_fm_md
    match = re_fm.search(s.strip())
    return yaml.safe_load(match.group(1)) if match else {}

This function is for notebooks with YAML frontmatter.

With nb2fasthtml the first notebook or Quarto/Markdown cell is frontmatter, and the function that reads it is customizable. By default it's:

def get_frontmatter(source,     # metatadata source (jupyter cell or md content)
                    nb_file=True,    # Is jupyter nb or qmd file
                    md_fm=False # md or raw style frontmatter
                   ):
    if not nb_file: return _fm2dict(source)
    if md_fm:       return _md2dict(source.source)
    return _fm2dict(source.source, nb_file) 

We can see that it works with Quarto .qmd files in addition to Jupyter .ipynb files.

Trying It on an Example Notebook

nb = read_nb(Path("/Users/arg/fun/arg-blog-fasthtml/nbs/2025-01-22-MonsterUI-Buttons-and-Links.ipynb"))
nb.cells[0]

This is how render_nb calls get_frontmatter:`

fm = get_frontmatter(nb.cells[0])
fm
_fm2dict(nb.cells[0].source, nb)

My Multi-Cell Frontmatter Format

At first I didn't like frontmatter. I realize now that it's just YAML frontmatter that bothers me, creating a bit of mental overhead. I want my daily notebooks to feel like regular Jupyter notebooks, and I can only get into flow with writing if I have nothing bothering me.

This is perhaps a bit ambitious, but I would love my daily notebooks on audrey.feldroy.com to have literate multi-cell frontmatter, inspired by the nicely literate Markdown llmstxt.org format. My spec for it:

Cell 0: Title in a Markdown Heading

nb.cells[0]

Cell 1: Short Description in a Markdown Paragraph

nb.cells[1]

This would be a separate cell, to allow me to grab it easily on the index page and in self-referential examples in future notebooks. I often use my old notebooks as examples in new notebooks.

I considered keeping it in one cell separated by \n\n, but I really do like it in a separate cell for ease of use. It's also nice to have less surface area for errors: sometimes I find myself forgetting if I need 1 line break or 2, and this helps me.

Cell 2: Imports

nb.cells[2]

This is a code cell containing the imports for my notebook. I always have at least 1 import. I need them for at least 1 Python frontmatter cell, so I make them my first Python cell.

Cell 3: Tags

nb.cells[3]

This is a code cell containing a Python list of tags. I would love to play with creating tag components, and this is convenient for me to grab and use.

Customizing Headers Per Route

Can FastHTML headers be customized differently for different routes?

The typical pattern is to set them at the app level, like app, rt = fast_app(hdrs=Theme.slate.headers()). Let's see if I can change this.

Cell 4: FastHTML Headers

This is a code cell containing a Python definition of my FastHTML headers for the particular page. My use case for this is that I like to experiment deeply with different FastHTML headers in different daily notebooks.

My use cases include:

I definitely don't want all my notebooks rendered by nb2fasthtml with the same FastHTML headers!

At the moment I'm not sure I like how I've defined this cell, but let's go with it and see what happens. I may get rid of it later.

How Do Headers Even Work in FastHTML()?

nb.cells[4]

I see this in fasthtml.core:

def def_hdrs(htmx=True, surreal=True):
    "Default headers for a FastHTML app"
    hdrs = []
    if surreal: hdrs = [surrsrc,scopesrc] + hdrs
    if htmx: hdrs = [htmxsrc,fhjsscr] + hdrs
    return [charset, viewport] + hdrs
def_hdrs()

I'm breaking down portions of FastHTML() here to try and understand better:

hdrs = def_hdrs()
hdrs
ftrs = None
exts = 'head-support'
hdrs,ftrs,exts = map(listify, (hdrs,ftrs,exts))

HTMX Extensions in FastHTML Headers

htmx_exts = {
    "head-support": "https://unpkg.com/htmx-ext-head-support@2.0.3/head-support.js",
    "preload": "https://unpkg.com/htmx-ext-preload@2.1.0/preload.js",
    "class-tools": "https://unpkg.com/htmx-ext-class-tools@2.0.1/class-tools.js",
    "loading-states": "https://unpkg.com/htmx-ext-loading-states@2.0.0/loading-states.js",
    "multi-swap": "https://unpkg.com/htmx-ext-multi-swap@2.0.0/multi-swap.js",
    "path-deps": "https://unpkg.com/htmx-ext-path-deps@2.0.0/path-deps.js",
    "remove-me": "https://unpkg.com/htmx-ext-remove-me@2.0.0/remove-me.js",
    "ws": "https://unpkg.com/htmx-ext-ws@2.0.2/ws.js",
    "chunked-transfer": "https://unpkg.com/htmx-ext-transfer-encoding-chunked@0.4.0/transfer-encoding-chunked.js"
}

Head Support

That's nice that FastHTML makes it easy to add these HTMX extensions. I went back and changed exts = None to exts = 'head-support' just to see what happens.

exts = {k:htmx_exts[k] for k in exts}
exts

How Scripts Are Added to Head

hdrs += [Script(src=ext) for ext in exts.values()]
hdrs

The htmx Head Tag Support Extension adds support for head tags in responses to htmx requests. That looks cool. I want to explore it another day.

Extra JS Headers for Notebook-Based FastHTML Apps

Exploring the stuff inside if IN_NOTEBOOK:

hdrs.append(iframe_scr)
hdrs

Ah, the HTMX() IFrame uses websockets and the JS is added like this. So much to explore another day!

Inside of if nb_hdrs there is:

display(HTML(to_xml(tuple(hdrs))))

Breaking that down:

to_xml(tuple(hdrs))
HTML(to_xml(tuple(hdrs)))

Ah, I think I see, this is how the headers are added to the Jupyter notebook. I am reading the IPython.display.HTML docs

Exception Handlers

A bit of a tangent, but they use hdrs so I'll explore them a little.

def _not_found(req, exc): return Div('nope')
exception_handlers={404:_not_found}
htmlkw,bodykw,body_wrap = None,None,None
def _wrap_ex(f, status_code, hdrs, ftrs, htmlkw, bodykw, body_wrap):
    async def _f(req, exc):
        req.hdrs,req.ftrs,req.htmlkw,req.bodykw = map(deepcopy, (hdrs, ftrs, htmlkw, bodykw))
        req.body_wrap = body_wrap
        res = await _handle(f, (req, exc))
        return _resp(req, res, status_code=status_code)
    return _f
excs = {k:_wrap_ex(v, k, hdrs, ftrs, htmlkw, bodykw, body_wrap=body_wrap) for k,v in exception_handlers.items()}
excs

Headers in FastHTML Routing

I still haven't found the exact spot where headers get added to a FastHTML route handler. Time to look at routing in more detail.

I see that route and _add_route have a body_wrap parameter, and that it also uses self.body_wrap.

%%ai
Where are headers added before the Div or other FT returned by the route handler?

Looking at the dialog, I can help trace where headers are added to route responses. The key is in the _resp function mentioned in _wrap_ex. When a route handler returns, its response goes through _resp which combines the headers with the content.

Let me help find the exact mechanism. Looking at your exploration, I see:

  1. Headers are stored in the request object: req.hdrs,req.ftrs,req.htmlkw,req.bodykw = map(deepcopy, (hdrs, ftrs, htmlkw, bodykw))
  2. Route responses go through: return _resp(req, res, status_code=status_code)

To find exactly where headers are added, we should look at the _resp function in fasthtml.

def _xt_resp(req, resp, status_code):
    cts,http_hdrs,tasks = _xt_cts(req, resp)
    return HTMLResponse(cts, status_code=status_code, headers=http_hdrs, background=tasks)
def _is_ft_resp(resp): return isinstance(resp, _iter_typs+(HttpHeader,FT)) or hasattr(resp, '__ft__')
def _resp(req, resp, cls=empty, status_code=200):
    if not resp: resp=()
    if hasattr(resp, '__response__'): resp = resp.__response__(req)
    if cls in (Any,FT): cls=empty
    if isinstance(resp, FileResponse) and not os.path.exists(resp.path): raise HTTPException(404, resp.path)
    if cls is not empty: return cls(resp, status_code=status_code)
    if isinstance(resp, Response): return resp # respect manually set status_code
    if _is_ft_resp(resp): return _xt_resp(req, resp, status_code)
    if isinstance(resp, str): cls = HTMLResponse
    elif isinstance(resp, Mapping): cls = JSONResponse
    else:
        resp = str(resp)
        cls = HTMLResponse
    return cls(resp, status_code=status_code)
%%ai
Any ideas?

Yes - I see that _xt_resp is called for FastHTML responses (when _is_ft_resp returns True). The key is in _xt_cts which we haven't seen yet. Let's look at that function - it's likely where the headers from req.hdrs get combined with the response content.

Would you like me to help find _xt_cts in the FastHTML source?

def _xt_cts(req, resp):
    resp = flat_tuple(resp)
    resp = resp + tuple(getattr(req, 'injects', ()))
    http_hdrs,resp = partition(resp, risinstance(HttpHeader))
    http_hdrs = {o.k:str(o.v) for o in http_hdrs}
    tasks,resp = partition(resp, risinstance(BackgroundTask))
    ts = BackgroundTasks()
    for t in tasks: ts.tasks.append(t)
    hdr_tags = 'title','meta','link','style','base'
    heads,bdy = partition(resp, lambda o: getattr(o, 'tag', '') in hdr_tags)
    if resp and 'hx-request' not in req.headers and not any(getattr(o, 'tag', '')=='html' for o in resp):
        title = [] if any(getattr(o, 'tag', '')=='title' for o in heads) else [Title(req.app.title)]
        resp = respond(req, [*heads, *title], bdy)
    return _to_xml(req, resp, indent=fh_cfg.indent), http_hdrs, ts

Okay! I see clues here. Breaking relevant parts of it down...

hdr_tags = 'title','meta','link','style','base'
heads,bdy = partition(resp, lambda o: getattr(o, 'tag', '') in hdr_tags)
heads
%%aip
Construct a resp
# Let's create a simple response with some header tags and body content
resp = (
    Title("Test Page"),
    Meta(name="description", content="A test page"),
    Link(rel="stylesheet", href="style.css"),
    Style("body { color: blue }"),
    Div("Some content"),
    P("More content")
)
resp
# Now try partition to see what gets separated into heads vs body
heads, bdy = partition(resp, lambda o: getattr(o, 'tag', '') in ('title','meta','link','style','base'))
print("Headers:", heads)
print("Body:", bdy)
%%ai
What does _xt_cts do?

Based on the code and our exploration, _xt_cts does several key things:

  1. Flattens and processes response tuple:

    resp = flat_tuple(resp)
    resp = resp + tuple(getattr(req, 'injects', ()))
    
  2. Extracts HTTP headers and background tasks:

    http_hdrs,resp = partition(resp, risinstance(HttpHeader))
    http_hdrs = {o.k:str(o.v) for o in http_hdrs}
    tasks,resp = partition(resp, risinstance(BackgroundTask))
    
  3. Separates HTML head tags from body content:

    heads,bdy = partition(resp, lambda o: getattr(o.tag, '') in ('title','meta','link','style','base'))
    
  4. For non-HTMX requests, wraps content in full HTML response:

    if resp and 'hx-request' not in req.headers and not any(getattr(o, 'tag', '')=='html' for o in resp):
        title = [] if any(getattr(o, 'tag', '')=='title' for o in heads) else [Title(req.app.title)]
        resp = respond(req, [*heads, *title], bdy)
    
  5. Returns tuple of:

    • XML-rendered content
    • HTTP headers dict
    • Background tasks

This is where FastHTML route responses get transformed into full HTML documents with proper head/body structure.

Whoa, that's so cool how the response gets partitioned.

%%ai
What could the cts in _xt_cts be short for? XT is like FT I think

Given the context and that it's transforming content into XML/HTML, cts is most likely short for "contents". So _xt_cts would be "transform contents" - where xt is like ft (FastHTML Transform) and cts is contents.

This makes sense since the function is all about transforming and organizing the contents of the response into proper HTML structure with headers, body, etc. before rendering to XML.

Checking If the Notebook Needs MonsterUI Headers

nb.cells[3].source
"MonsterUI" in nb.cells[3].source

A bit hacky but works for now. Maybe I don't need tags to be a Python list after all, haha. I'll leave it, though.

Conditionally Including MonsterUI Headers

Modifying the notebook handler that calles render_nb:

def get_title(nb):
    "Get title from `fname` notebook's cell 0 source by stripping '# ' prefix"
#     nbc = read_nb(fname)
    nb = nb.cells[0].source.lstrip('# ')
    if '\n' in nb:
        return first(nbc.split('\n'))
    return nb
@rt('/nbs/{name}')
def notebook(name: str):
    fpath = Path(f'nbs/{name}.ipynb')
    nb = read_nb(fpath)
    if "MonsterUI" in nb.cells[3].source:
        return (
            *Theme.blue.headers(),
            Title(get_title(nb)),
            render_nb(fpath, wrapper=Div)
        )

    # Otherwise use default style
    return (
        Title(get_title(nb)),
        Style(':root {font-family: system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", sans-serif; color-scheme: light dark;} body {background-color: light-dark(#ffffff, #1a1a1a); color: light-dark(#1a1a1a, #ffffff);} p {line-height: 1.5;}'),
        render_nb(fpath, wrapper=Div),
    )
name = "2025-01-22-MonsterUI-Buttons-and-Links"
fpath = Path(f'nbs/{name}.ipynb')
fpath
fpath.absolute()
notebook("2025-01-22-MonsterUI-Buttons-and-Links")
notebook("2025-01-21-SVG-Animation-in-FastHTML")

Reading that as best I can, I think the conditional header inclusion worked!

The true test of this is if it actually works when I bring it over to my arg-blog-fasthtml main.py. Let's see what happens.

Success

Whoa, I can't believe I managed to get that working:

Go check out the main.py in https://github.com/audreyfeldroy/arg-blog-fasthtml to see how it all fits together.

I suppose we didn't actually customize render_nb but rather customized the part before it. Then we left the hard work of header and body partitioning to _xt_cts.

from nb2fasthtml.core import *