from datetime import datetimeExample: add a post
Version 5
This version features the ability to import the module without problems with the app having to be created during the load. This allows it to be deployed easily with a python app file and to change from a Jupyter server to a more straightforward uvicorn server
Developments still to add in: 1. Add ability to delete posts from the UI if admin 2. Add ability to edit posts in situ 3. Check if there is a way to enable image layout in Obscidian
Sets the default export module for nbdev. All cells marked #| export will be written to my_blog/core_v5.py.
AppState
def AppState(
pdb:Database, posts_t:Table, tags_t:Table, post_tags_t:Table, auth:AuthManager, db:Database
)->None:
create_database_tables
def create_database_tables(
pdb:Database, # database to save posts and all associated tag tables
):
Call self as a function.
create_post_database
def create_post_database(
db_path:str, # path to posts database
)->Database: # Creates all the required database tables if they don't already exist
Call self as a function.
Convenience wrapper that creates a database connection and initializes all tables.
create_app
def create_app(
):
Call self as a function.
route
def route(
path:NoneType=None
):
Decorator to collect routes without registering them immediately. Use @route(‘/path’) or @route() for function-name-based paths.
register_routes
def register_routes(
app
):
Register all collected routes with the app.
logout
def logout(
sess
):
Overwrite logout route from fasthtml_auth to return users to home page after logging out
intro
def intro(
):
Call self as a function.
hx_link
def hx_link(
txt, href, cls:str='text-primary underline', target:str='#main-content', kw:VAR_KEYWORD
):
Call self as a function.
hx_attrs
def hx_attrs(
target:str='#main-content'
):
Call self as a function.
This dialogue is part of an nbdev project. When deployed to github the actions setup for publishing to github pages is resulting in two entries for this dialogue (see: https://fromlittleacorns.github.io/my-blog/). Can you use dialogue tools to explore why this might be happening
Yes please create the sidebar file. The pages looks like this:

You can see the duplication there
is_logged_in
def is_logged_in(
req
):
Call self as a function.
x_icon
def x_icon(
):
Call self as a function.
layout
def layout(
req, content:VAR_POSITIONAL, htmx, title:NoneType=None
):
section and will not be visible on the page. The article title is appended into the main content and shows below the navbar*
slug_exists
def slug_exists(
slug
):
Call self as a function.
get_slug
def get_slug(
title
):
Call self as a function.
blogpost
def blogpost(
htmx, req, slug:str
):
Call self as a function.
index
def index(
req, htmx
):
Call self as a function.
get_posts
def get_posts(
n:Optional=None, # number of posts to load
tags:Optional=None, # list of tags
logged_in:bool=False, # is user logged in
offset:Optional=None, # where to start to load posts once ordered
)->list: # list of posts
Call self as a function.
tag_pill
def tag_pill(
tag_name, selected_tags
):
Call self as a function.
tag_filter
def tag_filter(
selected
):
Call self as a function.
tag_badge
def tag_badge(
name
):
Call self as a function.
decode_tag_str
def decode_tag_str(
tag_str:str
):
Call self as a function.
test_tags_decode_str = 'cycling, motorhome, coding'
decode_tag_str(test_tags_decode_str)
assert decode_tag_str(test_tags_decode_str) == {'cycling', 'motorhome', 'coding'}sentinal
def sentinal(
n:int, # number of posts to load
offset:int=None, # where to start the load
tags:str=None, # a list of active tags separated by commas
)->functools.partial(<function ft_hx at 0x7fb9c1b385e0>, 'div'): # Div to add to the end of the list of posts to act as a sentinal
Create a div to add to the end of the posts on the blog. When it becomes visible then it activates the load of more posts
@route def blog(htmx, req, tags:str=None): logged_in = is_logged_in(req) posts_to_load = 10 # selected is a SET of the name of the selected tags selected = {unquote(t.strip()) for t in (tags or ’‘).split(’,’) if t.strip()} filtered = get_posts(n=posts_to_load, tags=selected, logged_in=logged_in) tag_filter_div = tag_filter(selected) items = [post_card(p, req) for p in filtered] if len(items) == posts_to_load: items.append(sentinal(offset=0, n=posts_to_load, tags=tags))
post_content = Div(*items, cls="space-y-2", id="posts-list") if items else P("No posts yet.", cls="text-muted-foreground", id="posts-list")
if htmx and htmx.target == "posts-list":
tag_filter_div.attrs['hx-swap-oob'] = 'true'
return post_content, tag_filter_div
return layout(req, H2("Blog"), tag_filter_div, Divider(cls=('my-2')), post_content, title="Blog", htmx=htmx)
blog
def blog(
htmx, req, tags:str=None
):
Call self as a function.
get_more_posts
def get_more_posts(
req, n:int, offset:int, tags:str=None
):
Call self as a function.
I am inplementing an infinte scroll using a sentinal and the /loadmore route. When I first load /blog it loads 10 posts and has the sentinal at the end with the following:But when the sentinal is activated no more posts are loaded, when I know there should be more (13 in total)? Can you see why this might happen?
I have updated sentinal, does this look ok def sentinal(n:int, offset:int=None, tags: str=None): # n: int posts to load # offset: int where to load from # tags: str string containing selected tags with comma separation get_str = f”/loadmore?offset={offset}&n={n}” if tags: get_str += f”&tags={tags}” sentinal = Div( hx_get=get_str,
hx_trigger=“intersect once”, hx_swap=“outerHTML”, hx_target=“this” ) return sentinal
I guess I can check the tags passed in and if there are none, then not call the tags parameter so that it will default to None in the loadposts function.
In the htmx online guidebook they use a htmx trigger “revealled”. Is this available in fastHTML and if so why use the intersect once. The full code they have is: {% if contacts|length == 10 %} Loading More…{% endif %}
I am happy to use the intersect once, I just wanted to understand the difference. Can you elaborate on the selection. I understand the filter they use just results in new rows being selected. In your example you have a target of “this”, what is that and how do you avoid getting headers etc?
Ok that makes sense and I think our approach is more efficient. Do I need to start by setting up pagination within get_posts, or should we create a new way to retrieve the pages?
Ok I have updated get_posts (I created a new version after the original)
given that the call to get_more_posts is to load more posts and that it uses outerHTML as a swop, I was returning additional posts and if necessary a new sentinal, what is the correct syntax for that?
I am confused by your third comment above, I am setting the sentinal offet so that it will load posts after the first load is complete, is that not correct?
I can see that when a call is made to /blog/more, ir is going to /blog/{slug}. how would you recomment i change the routes to avoid this. its a pain to change the route registration order, i guess i could just rename it to /load_more ir similar?
get_post_image
def get_post_image(
p
):
Call self as a function.
check_if_admin
def check_if_admin(
req
):
Call self as a function.
post_card
def post_card(
p, req
):
Post summary card with optional admin controls
add_post
def add_post(
title, content, excerpt:str='', tags:NoneType=None, published:bool=True, created:NoneType=None,
updated:NoneType=None, slug:str=None, private:bool=False
):
Call self as a function.
def post_exists(slug: str) -> bool:
return bool(_state.posts_t(where='slug=?', vals=[slug]))process_upload
def process_upload(
content:bytes, filename:str, slug:str=None, overwrite:bool=False
):
Call self as a function.
get
def get(
htmx
):
Call self as a function.
save_pending
def save_pending(
slug:str, md_name:str, md_content:bytes, images:list
):
Save md content and images to temp storage keyed by slug. images is a list of (filename, bytes) tuples.
load_pending
def load_pending(
slug:str
):
Returns (md_content, images) or None if not found.
clear_pending
def clear_pending(
slug:str
):
Call self as a function.
@route('/admin/upload')
def post(upload2: list[UploadFile]):
files = [(f.filename, f.file.read()) for f in upload2]
md_files = [(n, c) for n, c in files if n.endswith('.md')]
img_files = [(n, c) for n, c in files if not n.endswith('.md')]
results = []
slug = None
if img_files and len(md_files)==0:
return Div(Alert("Please upload a post (.md file) with images, or upload images separately", cls=AlertT.warning))
for name, content in md_files:
success, message, slug = process_upload(content, name)
results.append((name, success, message))
for name, content in img_files:
success, message, _ = process_upload(content, name, slug=slug)
results.append((name, success, message))
header = ["Name", 'Success', 'Message']
body = [[r[0], r[1], r[2]] for r in results]
return Div(H2("Post upload results"), TableFromLists(header, body))do_upload
def do_upload(
md_files, img_files, slug:str=None, overwrite:bool=False
):
Call self as a function.
post
def post(
upload2:list
):
Call self as a function.
post
def post(
slug:str
):
Call self as a function.
end-section
I don’t think this is correct as the filename of the md file is not necessarily going to be slug + ‘.md’. The filename surely needs to come from the originally supplied name? I think that this flaw is built into the load and save pending functions as well. Do you agree or have I misunderstood?
rewrite_image_paths
def rewrite_image_paths(
content:str, slug:str
)->str:
Call self as a function.
load_md_file
def load_md_file(
path:str, image_base:str=None
)->str:
Load markdown file, optionally converting Obsidian image syntax
convert_obsidian_images
def convert_obsidian_images(
content:str, image_base:str='/static/image/about'
)->str:
Convert Obsidian ![[image.ext]] syntax to standard markdown
test_img_1_content = '''test string with obscidian markdown, first without a table:
![[IMG_3932.jpeg]]
next with size:
![[IMG_3932.jpeg|250]]
and with size and position:
![[IMG_3932.jpeg|250|right]]
Then some rubbish
'''
base_path = "/static/image/post_images/post_1"
converted = convert_obsidian_images(test_img_1_content, base_path)
converted'test string with obscidian markdown, first without a table:\n\nnext with size:\n\nand with size and position:\n\nThen some rubbish\n'
about_content
def about_content(
):
Call self as a function.
about
def about(
req, htmx
):
Call self as a function.
strava_embed
def strava_embed(
activity_id:str
):
Call self as a function.
# In your markdown, use:
# {{strava:12345678}}
# This will be replaced with an embedded Strava activityReturns a Strava embed div with the given activity ID. The Strava embed.js script (loaded in headers) will transform this into a full embed.
process_strava_embeddings
def process_strava_embeddings(
page:NotStr
):
Call self as a function.
Post-processes rendered HTML to find {strava:ID} placeholders and replace them with actual Strava embed divs.
process_obsidian_images
def process_obsidian_images(
page:NotStr, image_base:str
)->NotStr:
Call self as a function.
# Obsidian image syntax examples:
# ![[image.jpg|300]] - width 300px
# ![[image.jpg|300|right]] - float right, text wraps
# ![[image.jpg|300x200|center]] - fixed size, centeredPost-processes rendered HTML to find Obsidian image syntax ![[image.jpg|width|position]] and replace with styled <img> tags. Supports width, height, and positioning (left/right/center).
Additional imports for the custom markdown renderer.
EnhancedRenderer
def EnhancedRenderer(
args:VAR_POSITIONAL, img_dir:NoneType=None, kwargs:VAR_KEYWORD
):
Custom renderer for Franken UI that handles image paths
@route('/googleada316577537ad2b.html')
def get(): return 'google-site-verification: googleada316577537ad2b.html'sitemap
def sitemap(
):
Call self as a function.
Custom markdown renderer that extends FrankenRenderer. Automatically adds target="_blank" and rel="noopener noreferrer" to external links (http/https).
Export
nbdev.nbdev_export()Exports all cells marked with #| export to the my_blog/core_v5.py module file.
social_link
Call self as a function.