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
audrey.feldroy.com
The experimental notebooks of Audrey M. Roy Greenfeld. This website and all its notebooks are open-source at github.com/audreyfeldroy/audrey.feldroy.com
# 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?
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:
- Sometimes I want vanilla CSS and JS, and other times I want the MonsterUI headers.
- If I'm using daily notebooks to explore code cell rendering, sometimes I want to render code cells with HighlightJS, other times with Pygments or another library.
- If I'm playing with Pico or Bootstrap, I want to add it to the headers.
- Maybe I'll start exploring the different HTMX extensions and add them to headers in one-off notebooks.
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:
^---\s*
- Matches start of text (^
), three dashes, and optional whitespace(.*?\S+.*?)
- Captures content between dashes:.*?
- Any chars (non-greedy)\S+
- At least one non-whitespace char.*?
- Any chars (non-greedy)
---\s*
- Matches three dashes and optional whitespace$
- End of text (withre.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]
{ 'cell_type': 'markdown',
'idx_': 0,
'metadata': {},
'source': '# MonsterUI Buttons and Links'}
{'cell_type': 'markdown',
'metadata': {},
'source': '# MonsterUI Buttons and Links',
'idx_': 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_type': 'markdown',
'idx_': 0,
'metadata': {},
'source': '# MonsterUI Buttons and Links'}
{'cell_type': 'markdown',
'metadata': {},
'source': '# MonsterUI Buttons and Links',
'idx_': 0}Cell 1: Short Description in a Markdown Paragraph
nb.cells[1]
{ 'cell_type': 'markdown',
'idx_': 1,
'metadata': {},
'source': 'Iterating through the `ButtonT` enum to show all MonsterUI button '
'types visually.'}
{'cell_type': 'markdown',
'metadata': {},
'source': 'Iterating through the `ButtonT` enum to show all MonsterUI button types visually.',
'idx_': 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]
{ 'cell_type': 'code',
'execution_count': 4,
'idx_': 2,
'metadata': {},
'outputs': [],
'source': 'from fastcore.utils import *\n'
'from fasthtml.common import *\n'
'from monsterui.all import *'}
{'cell_type': 'code',
'execution_count': 4,
'metadata': {},
'outputs': [],
'source': 'from fastcore.utils import *\nfrom fasthtml.common import *\nfrom monsterui.all import *',
'idx_': 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]
{ 'cell_type': 'code',
'execution_count': 2,
'idx_': 3,
'metadata': {},
'outputs': [],
'source': 'tags = ["MonsterUI"]'}
{'cell_type': 'code',
'execution_count': 2,
'metadata': {},
'outputs': [],
'source': 'tags = ["MonsterUI"]',
'idx_': 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:
- Sometimes I want vanilla CSS and JS, and other times I want the MonsterUI headers.
- If I'm using daily notebooks to explore code cell rendering, sometimes I want to render code cells with HighlightJS, other times with Pygments or another library.
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]
{ 'cell_type': 'code',
'execution_count': 5,
'idx_': 4,
'metadata': {},
'outputs': [],
'source': 'hdrs = (Theme.blue.headers(), MarkdownJS(), '
"HighlightJS(langs=['python', 'javascript', 'html', 'css']))"}
{'cell_type': 'code',
'execution_count': 5,
'metadata': {},
'outputs': [],
'source': "hdrs = (Theme.blue.headers(), MarkdownJS(), HighlightJS(langs=['python', 'javascript', 'html', 'css']))",
'idx_': 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:
- Headers are stored in the request object:
req.hdrs,req.ftrs,req.htmlkw,req.bodykw = map(deepcopy, (hdrs, ftrs, htmlkw, bodykw))
- 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:
-
Flattens and processes response tuple:
resp = flat_tuple(resp) resp = resp + tuple(getattr(req, 'injects', ()))
-
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))
-
Separates HTML head tags from body content:
heads,bdy = partition(resp, lambda o: getattr(o.tag, '') in ('title','meta','link','style','base'))
-
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)
-
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")
<button type="submit" class="uk-button uk-button-default"></button>\n
\n',),{})),{}), article((div(('\n```python\nshow(Button("Click me"))\n```\n',),{'class': 'marked'}), footer(('',),{})),{}), article((div(('\n```python\nshow(A("My link", href="example.com", cls=AT.muted))\n```\n',),{'class': 'marked'}), footer(('My link',),{})),{}), article((div(('\n```python\nshow(NavBar(title="MySite", nav_links=(A("Link 1"), A("Link 2"))))\n```\n',),{'class': 'marked'}), footer(('<enum 'ButtonT'>
',),{})),{}), article((div(('\n```python\n[t.name for t in ButtonT]\n```\n',),{'class': 'marked'}), footer(('['default', 'primary', 'secondary', 'danger', 'text', 'link', 'ghost']
',),{})),{}), div((div(('**AI Prompt**\n\nIs there a convenience function in enum to list all these?',),{'class': 'marked'}),),{}), div((div(('**AI Response**\n\nYes! The `Enum` class in Python has a built-in method called `__members__`. You can use it like this:\n\n```python\nButtonT.__members__\n```\n\nWould you like me to explain more about what this returns and how it differs from the list comprehension approach you used?',),{'class': 'marked'}),),{}), article((div(('\n```python\nButtonT.__members__\n```\n',),{'class': 'marked'}), footer(('mappingproxy({'default': <ButtonT.default: 'uk-button-default'>,\n 'primary': <ButtonT.primary: 'uk-button-primary'>,\n 'secondary': <ButtonT.secondary: 'uk-button-secondary'>,\n 'danger': <ButtonT.danger: 'uk-button-danger'>,\n 'text': <ButtonT.text: 'uk-button-text'>,\n 'link': <ButtonT.link: 'uk-button-link'>,\n 'ghost': <ButtonT.ghost: 'uk-button-ghost'>})
',),{})),{}), article((div(('\n```python\nL(ButtonT.__members__)\n```\n',),{'class': 'marked'}), footer(('(#7) ['default','primary','secondary','danger','text','link','ghost']
',),{})),{}), article((div(('\n```python\ndef _Button(t): return Button(f"{t} button",cls=t)\nbtns = L(ButtonT.__members__).map(_Button)\nbtns\n```\n',),{'class': 'marked'}), footer(('(#7) [button(('default button',),{'type': 'submit', 'class': 'uk-button default'}),button(('primary button',),{'type': 'submit', 'class': 'uk-button primary'}),button(('secondary button',),{'type': 'submit', 'class': 'uk-button secondary'}),button(('danger button',),{'type': 'submit', 'class': 'uk-button danger'}),button(('text button',),{'type': 'submit', 'class': 'uk-button text'}),button(('link button',),{'type': 'submit', 'class': 'uk-button link'}),button(('ghost button',),{'type': 'submit', 'class': 'uk-button ghost'})]
',),{})),{}), article((div(('\n```python\nto_xml(Div(*btns))\n```\n',),{'class': 'marked'}), footer((''<div>\\n<button type="submit" class="uk-button default">default button</button><button type="submit" class="uk-button primary">primary button</button><button type="submit" class="uk-button secondary">secondary button</button><button type="submit" class="uk-button danger">danger button</button><button type="submit" class="uk-button text">text button</button><button type="submit" class="uk-button link">link button</button><button type="submit" class="uk-button ghost">ghost button</button></div>\\n'
',),{})),{}), article((div(('\n```python\nshow(to_xml(Div(*btns)))\n```\n',),{'class': 'marked'}), footer((''uk-button-default'
',),{})),{}), article((div(('\n```python\nDiv(*[Button(v,cls=v) for v in list(ButtonT)])\n```\n',),{'class': 'marked'}), footer(('<div>\n<button type="submit" class="uk-button uk-button-default">uk-button-default</button><button type="submit" class="uk-button uk-button-primary">uk-button-primary</button><button type="submit" class="uk-button uk-button-secondary">uk-button-secondary</button><button type="submit" class="uk-button uk-button-danger">uk-button-danger</button><button type="submit" class="uk-button uk-button-text">uk-button-text</button><button type="submit" class="uk-button uk-button-link">uk-button-link</button><button type="submit" class="uk-button uk-button-ghost">uk-button-ghost</button></div>\n\n
\n',),{})),{}), article((div(('\n```python\nshow(Div(*[Button(v,cls=v) for v in list(ButtonT)]))\n```\n',),{'class': 'marked'}), footer(('notebook("2025-01-21-SVG-Animation-in-FastHTML")
"<animate attributeName='rx' values='0;5;0' dur='10s' repeatCount='indefinite' />"
',),{})),{}), article((div(('\n```python\n@delegates(ft_svg)\ndef AnimatedRect(animate, width, height, x=0, y=0, fill=None, stroke=None, stroke_width=None, rx=None, ry=None, **kwargs):\n "An animated standard SVG `rect` element"\n return ft_svg(\'rect\', animate, width=width, height=height, x=x, y=y, fill=fill,\n stroke=stroke, stroke_width=stroke_width, rx=rx, ry=ry, **kwargs)\n```\n',),{'class': 'marked'}), ''),{}), article((div(('\n```python\nshow(Svg(AnimatedRect(\n Animate(attributeName="rx", values="0;5;0", dur="10s", repeatCount="indefinite"), \n width=10, height=10), h=10, w=10))\n```\n',),{'class': 'marked'}), footer(('',),{})),{}), article((div(('\n```python\nshow(Svg(AnimatedRect(\n Animate(attributeName="rx", values="0;50;0", dur="10s", repeatCount="indefinite"), \n width=100, height=100), h=100, w=100))\n```\n',),{'class': 'marked'}), footer(('',),{})),{}), article((div(('\n```python\ndemo(AnimatedRect(\n Animate(attributeName="rx", values="0;50;0", dur="1s", repeatCount="indefinite"), \n width=100, height=100), h=100, w=100)\n```\n',),{'class': 'marked'}), footer(('',),{})),{}), div((div(('## More Complex SVG Animation',),{'class': 'marked'}),),{}), article((div(('\n```python\nsvg4 = """"""\nshow(NotStr(svg4))\n```\n',),{'class': 'marked'}), footer(('',),{})),{}), div((div(('I kind of like this one for representing "a sound is currently playing" for experimental audio apps.',),{'class': 'marked'}),),{})),{'class': 'container'}))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:
- https://audrey.feldroy.com/nbs/2025-01-22-MonsterUI-Buttons-and-Links shows MonsterUI styles
- https://audrey.feldroy.com/nbs/2025-01-21-SVG-Animation-in-FastHTML shows my vanilla CSS styles without MonsterUI
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 *
© 2024-2025 Audrey M. Roy Greenfeld