Project 2: Markdown Preview

Project 2: Markdown Preview

November 29 2021

Introduction:

For this project, we would build a blog using HTMX and Django. Specifically, we would focus on a "preview" feature.

In a traditional Django application without HTMX, we would create the blog in a "create" view and save it to the database. If we want to see it, then we go to a "detail" view and see it how a reader would. If we want to edit it, then we would need an "edit" view.

In this pattern, we don't get to see what our blog would look like to the reader as we are typing. This is not ideal, especially if we are writing our blog in markdown since we aren't sure how things would look like until we hit the submit button. It would be nice to see what our blog would look while we are working on it.

Now, we can solve this problem in several ways. First, there is an excellent Django package that does this already for us, django-markdownx . Another solution is to build a React (or Vue) app inside our Django application that does that. Here is my react implementation for this feature: markdownpreviewer. This is what it would look like: https://jonathan-adly.github.io/markdownpreviewer/

What we would do though is use HTMX to implement this feature. It is by far the easiest option to implement, maintain, and customize to our liking. Here are our user stories for this project,

  • I can see a blog list on the home page
  • I can see an individual blog with the correct page title and URL
  • I can access the blog internally as a SPA or externally by sharing a link
  • On creating blogs, I want to see a preview of what my blog would look like
  • I want to use markdown to create my blog, and still see a preview

This project builds on the previous tutorials in this course. It is recommended that you go through them first,

  1. Django boilerplate
  2. Project 0: The HTMX/Django Stack
  3. Project 1: To Do App

This is what we will go over:


Configuration

Getting Started:

To get started, we will git clone our starter code

https://github.com/Jonathan-Adly/htmx-blog/tree/boilerplate

Then let's build our docker container and migrate

docker compose up -d --build

docker compose exec web python manage.py migrate

We should have a basic Django project with three apps.

The first is our config app. This application holds our setting. It is the same as the minimal boilerplate we went over in a previous tutorial. We only added our blog app to the INSTALLED_APPS list and our blog app URLs to our urls.py module.

The second app, "accounts"  manages our users. We don't have any changes there from our boilerplate.

The last app is our blog application. Where we have a basic setup: a blog model, a home view returning a blog list, and a matching endpoint.

This is already done for you, so no need to copy it.

# in blog/models.py
from django.db import models
from django.utils.text import slugify


class Blog(models.Model):
    title = models.CharField(max_length=250)
    content = models.TextField()
    slug = models.SlugField()

    def __str__(self):
        return self.title

    def get_absolute_url(self):
        return reverse("blog", kwargs={"slug": self.slug})

    def save(self, *args, **kwargs):
        if not self.slug:
            self.slug = slugify(self.title)
        super(Blog, self).save(*args, **kwargs)

Here in our model, we have three straightforward methods. A method to get a string representation of the model. Another method to get the model URL. Lastly, we are overriding the save method to slugify our title if no slug was provided.

Here is our view,

#in blogs/views.py
from django.shortcuts import render
from .models import Blog


def home(request):
    blogs = Blog.objects.all()
    return render(request, "home.html", {"blogs": blogs})

And this is our urls.py

#in blog/urls.py
from django.urls import path
from . import views

urlpatterns = [
    path("", views.home, name="home"),
]

Also, we are registering our Model to the admin so we can test if everything works quickly.

# in blogs/admin.py
from django.contrib import admin
from .models import Blog

# we are pre-populating out the slug field.
class BlogAdmin(admin.ModelAdmin):
    prepopulated_fields = {"slug": ("title",)}


admin.site.register(Blog, BlogAdmin)

Last thing we have is a base template with a navbar and the HTMX CDN. As in our first project, we are using the halfmoon CSS framework. We also have a basic home template to display our tasks.

Let's go ahead and create a superuser, and then add a blog in the admin to test things out.

docker compose exec web python manage.py createsuperuser

Username: testadmin - Email: testadmin@test.com - Password: testpass123

You should see something like this for a blog titled "How I learned to Code".

This takes care of user story #1.

Clean up

Before moving on to the rest of our user stories, let's go ahead and clean our code. First, we would make a component directory, and move our navbar in there.

mkdir templates/components

touch templates/components/navbar.html

We also want to make our login and logout links conditional in the navbar, so if we are logged in, we can log out.

Here is our new navbar with conditional rendering. We are using the HTMX pattern of logout confirmation that we went through in project 1.

  <!-- Navbar start -->
    <nav class="navbar">
      <!-- Reference: https://www.gethalfmoon.com/docs/navbar -->
       <button 
       class="btn btn-action mr-5" 
       type="button" 
       onclick="halfmoon.toggleDarkMode()"
       aria-label="Toggle dark mode">
       <svg width="1em" height="1em" viewBox="0 0 16 16" class="bi bi-circle-half" fill="currentColor"
                    xmlns="http://www.w3.org/2000/svg">
                    <path fill-rule="evenodd" d="M8 15V1a7 7 0 1 1 0 14zm0 1A8 8 0 1 1 8 0a8 8 0 0 1 0 16z" />
                </svg>
            </button>
            <!-- Navbar brand -->
            <a href="{% url 'home' %}" class="navbar-brand">
                HTMX-blog
            </a>
            <ul class="navbar-nav d-none d-md-flex ml-auto">
            {% if user.is_authenticated %}
                <li 
                class="nav-item"
                hx-post="{% url 'account_logout' %}"
                hx-trigger= "click"
                hx-confirm="Are you sure you wish to log out?"
                hx-target= "#content">
                <span class="nav-link"> Log out </span>
                </li>
            
            {% else %}
                <li class="nav-item">
                    <a 
                    href="{% url 'account_login' %}" 
                    class="nav-link"> log in</a>
                </li>
            {% endif %}
            </ul>
              
            <div class="navbar-content d-md-none ml-auto">
                <div class="dropdown with-arrow">
                    <button class="btn" 
                    data-toggle="dropdown" 
                    type="button" 
                    id="navbar-dropdown-toggle-btn-1">
                        Menu
                    </button>

                    <div 
                    class="dropdown-menu dropdown-menu-right w-200" 
                    aria-labelledby="navbar-dropdown-toggle-btn-1">
                        {% if user.is_authenticated %}
                        <span 
                        class="nav-link" 
                        hx-post="{% url 'account_logout' %}"
                        hx-trigger= "click"
                        hx-confirm="Are you sure you wish to log out?"
                        hx-target= "#content">
                        Log out 
                        </span>
                        
                        {% else %}
                        <a href="{% url 'account_login' %}" 
                        class="nav-link">Log in</a>
                        {% endif %}
                    </div>
                </div>
            </div>

    </nav>
    <!-- Navbar end -->

Now that our navbar is a component, let's clean up our base.html by using the include Django tag, and giving our content wrapper an ID for future HTMX use.

<!DOCTYPE html>
<html lang="en">
<head>
  <!-- Meta tags -->
  <meta charset="utf-8" />
  <meta http-equiv="X-UA-Compatible" content="IE=edge,chrome=1" />
  <meta content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=0" name="viewport" />
  <meta name="viewport" content="width=device-width" />

  <!-- Favicon and title -->

  <title> HTMX-blog </title>

  <!-- Halfmoon CSS -->
  <link href="https://cdn.jsdelivr.net/npm/halfmoon@1.1.1/css/halfmoon-variables.min.css" rel="stylesheet" />
</head>
<body class="with-custom-webkit-scrollbars with-custom-css-scrollbars" data-set-preferred-mode-onload="true">
  <!-- Modals go here -->
  <!-- Reference: https://www.gethalfmoon.com/docs/modal -->

  <!-- Page wrapper start -->
  <div class="page-wrapper with-navbar">

    <!-- Sticky alerts (toasts), empty container -->
    <div class="sticky-alerts"></div>

  {% include "components/navbar.html" %} <!-- new -->

    <!-- Content wrapper start -->
    <div class="content-wrapper" id= "content"> <!-- new -->
      {% block content %}

        {% endblock content %}
    </div>
    <!-- Content wrapper end -->

  </div>
  <!-- Page wrapper end -->

  <!-- Halfmoon JS -->
  <script src="https://cdn.jsdelivr.net/npm/halfmoon@1.1.1/js/halfmoon.min.js"></script>

  <!-- HTMX CDN -->
   <script src="https://unpkg.com/htmx.org@1.1.0"></script>
   <!-- handling CSRF request -->
    <script>
      document.body.addEventListener('htmx:configRequest', (event) => {
        event.detail.headers['X-CSRFToken'] = '{{ csrf_token }}';
      })
    </script>
</body>
</html>

Last thing we will do is make our blog list look a little nicer by putting a card around. Here is our new home.html

{% extends 'base.html' %}

{% block content %}
<div class="card text-center">
<h2 class="card-title"> Blogs </h2>
<ul> 
{% for blog in blogs %}
<li> {{blog.title}} </li>
{% empty %}
No blogs yet.
{% endfor %}
</ul>
</div>
{% endblock content %}

Now we are ready to tackle our user stories.


Core Functionality

Retrieve Blog

Our next user story to handle is "I can see individual blog with the correct page title and URL". Let's go ahead and do this the Django way, then see how can we use HTMX to keep our application as a single page.

First, let's add a URL endpoint and a view for our blog.

#in blog/urls.py
urlpatterns = [
    path("", views.home, name="home"),
    path("blog/<slug:slug>", views.blog, name="blog"), #new
]

# in blog/views.pu
from django.shortcuts import render, get_object_or_404 #new
from .models import Blog

#new
def blog(request, slug):
    blog = get_object_or_404(Blog, slug=slug)
    return render(request, "blog.html", {"blog": blog})

Then we need to build our template to render our blog.

{% extends 'base.html' %}

{% block content %}
<div class="container-fluid">

    <div class="card w-lg-half w-full mx-auto">
        <h2 class= "card-title">{{blog.title}} </h2>
        {{blog.content}}
        
    </div>

</div>

{% endblock content %}

Finally, we are going to add our blog link to our home page, so we can go to it by clicking on the title. That's where our get_absolute_url method comes in handy!

{% extends 'base.html' %}

{% block content %}
<div class="card text-center">
<h2 class="card-title"> Blogs </h2>
<ul> 
{% for blog in blogs %}
<li> <a href="{{blog.get_absolute_url}}">{{blog.title}} </a> </li>
{% empty %}
No blogs yet.
{% endfor %}
</ul>
</div>
{% endblock content %}

If you try to go to your home page, you will notice the Django debugging page. The error is "name 'reverse' is not defined". This is exactly why we develop our features the old-fashioned way first, then add HTMX. It's a lot easier to catch and fix bugs this way.

This bug was caused because we never imported the function "reverse" in our models.py file. We used "reverse" in our get_absolute_url method. So, let's go ahead and fix our bug by adding a new line to our models.py

from django.db import models
from django.urls import reverse #new
from django.utils.text import slugify


class Blog(models.Model):
    #nothing changes here

Now, our home page should work.  If we click on our blog title, it should take us to the blog details template. We have the correct URL in our URL bar, but our page title didn't change. Ideally, we would want our title to match our blog title.

We can easily do this with a Django title block.

First, we will change our base to make our title dynamic

<!-- in base.html -->
 <title>
        {% block title %} HTMX-blog {% endblock title %}
 </title>

Then, in our blog template, we will provide the right title.

<!-- in blog.html -->
{% extends 'base.html' %}
{% block title %} {{blog.title}} {% endblock title %} <!-- new -->
{% block content %}
<div class="container-fluid">

    <div class="card w-lg-half w-full mx-auto">
        <h2 class= "card-title">{{blog.title}} </h2>
        {{blog.content}}
        
    </div>

</div>

Now, when we click on the link for our blog, our page title changes. Our user story is done, but it's not a single-page application. So, let's tackle this next.

hx-boost

First, let's change our link to use HTMX instead. We would use the magical hx-boost this time, instead of the usual hx-get.

<ul> 
{% for blog in blogs %}
<li hx-boost="true"> 
<a href="{{blog.get_absolute_url}}">{{blog.title}} </a> 
</li>
{% empty %}
No blogs yet.
{% endfor %}
</ul>

That's it. Here we cut the usual 5 lines of hx attributes by just one. Before, we would need to write an hx-get, hx-swap, hx-target, hx-trigger, and hx-push-url. Now, we just need hx-boost and the rest is abstracted for us.

The way hx-boost works is by abstracting some of the code that we need to write. It is a hx-get that is targeting the body element with an innerHTML swap with a click trigger. Also, it pushes the URL to the URL bar.

Anytime you have a traditional <a> tag and want to turn it into an HTMX, simply wrap its parent element with the hx-boost attribute! As a bonus, hx-boost even works if Javascript is disabled by falling back to the normal <a> tag behavior.

Moreover, hx-boost gives you reasonable defaults for your attributes. But you can override them if you want. Let's say we want to put our blog details on the same page as our blog list. In other words, our hx-target is not the body element, but another div. No problem, just override it!

(We won't do that though, we want our blog in its own page)

<!-- FYI only, we won't do that -->
{% extends 'base.html' %}

{% block content %}
<div class="card text-center">
<h2 class="card-title"> Blogs </h2>
<ul> 
{% for blog in blogs %}
<li hx-boost="true" hx-target="#test">  <!-- new -->
<a href="{{blog.get_absolute_url}}">{{blog.title}} </a> 
</li>
{% empty %}
No blogs yet.
{% endfor %}
</ul>
</div>

<div id="test"> </div> <!-- new -->
{% endblock content %}

Now, go ahead and test your link (with the blog on its own page). You will notice that we didn't lose any of the Django functionality with the title and the URL bar but simply added a SPA architecture with 1 extra line of code.

SEO

Let's go ahead and see if our next user story works already by opening a private browser and copying and pasting our URL. Here is the story: "I can access the blog internally as a SPA or externally by sharing a link"

It works right? Great! That's another thing off our plate. Now, if you are a Django-first developer, this user story might seem weird to you. Of course, a link is a link, it doesn't matter if it's an internal link or coming from outside.

But I put that story intentionally because it is usually a pain point for React and Vue-first developers. When we rely exclusively on javascript to handle our routing, it does so via the browser history. This can cause some weird bugs and side effects.

For us though, we don't have to worry about this issue at all. If we click an internal link, HTMX would use javascript to handle routing. But if someone is coming with an external link, Django would serve the template from the server and no javascript is involved.

This is also helpful from an SEO standpoint. We used a Django title block to dynamically update our title. We could do the same to update anything we want in our head block. So, when someone shares a link on social media, crawlers fetch it from the server and render our SEO elements.

Further, our hx-boost has a fallback when javascript is not working. So, search engine crawlers shouldn't have trouble finding our pages.

Here is an example block SEO. Like our title block, we would have generic values in our base template, then update it with blog-specific values in our blog template.

{% block seo %}
<!-- Facebook meta tags -->
<meta property="og:title" content="{{blog.title}}" />
<meta property="og:description" content="Read the post on myblog.com" />
<meta property="og:image" content="{{blog.main_img.url}}" />
<meta property= "og:url" content={{blog.get_absolute_url}}"" />
<!-- Twitter meta tags -->
<meta name="twitter:card" content="summary_large_image" />
<meta name="twitter:site" content="@myblog" />
<meta name="twitter:creator" content="@myblog" />
<meta name="twitter:title" content="{{blog.title}}" />
<meta name="twitter:description" content="Read more on myblog.com" />
<meta name="twitter:image" content="{{blog.main_img.url}}" />


<!-- Search engine meta tags -->
<meta name="description" content="{{blog.title}}" />
{% endblock seo %}

So with HTMX, we are getting the best of both worlds. Snappy navigation internally with javascript, and server rendering for link sharing and SEO.

Now, let's go ahead and git commit our work before moving on to our next user stories. You can see the code up to this point here.

Create Blog

Our next user story is "On creating blogs, I want to see a preview of what my blog would look like".

To start, we would need a create URL endpoint, a form to submit the data with, and a view to handle submission.

Also, since we are working with forms, let's go ahead and download the django-crispy-forms package to make our forms look nice.

docker compose exec web pipenv install django-crispy-forms

Then, let's register our package in our settings.py module, and specify that we want our forms styled like Bootstrap 4 (which halfmoon is based on).

INSTALLED_APPS = [
    ...
    #nothing else changed
    "crispy_forms",
]
CRISPY_TEMPLATE_PACK = "bootstrap4"

Since we changed our pipfile by installing a new package, to see the effects we have to reboot our docker container.

docker compose down
docker compose up -d --build

Next, let's build our form using Django forms.

touch blog/forms.py

This is the code for our model form,

from django import forms
from .models import Blog


class BlogForm(forms.ModelForm):
    class Meta:
        model = Blog
        fields = ("title", "content")

We have a method to slugify our title automatically, so we don't need that field.

And here is our urls.py and views.py.

# in blog/urls.py
from django.urls import path
from . import views

urlpatterns = [
    path("", views.home, name="home"),
    path("create", views.create, name="create"), #new
    path("blog/<slug:slug>", views.blog, name="blog"),
]
# in blog/views.py
from .forms import BlogForm #new

def create(request):
    form = BlogForm()
    return render(request, "create_blog.html", {"form": form})

Lastly, we need to build our html template. We know that we are going to have preview there eventually, so we will go ahead and build it now.

touch templates/create_blog.html

{% extends 'base.html' %}
{% load crispy_forms_tags %}

{% block content %}
<div class="container-fluid">
    <div class="row">
        <div class= "col-6">
            <div class="card" id="form">
            <form method = "post">
            {% csrf_token %}
                {{form|crispy}}
                <button class="btn"> Submit </button>
            </form>
            </div>
        </div>

        <div class= "col-6">
            <div class="card" id="preview">
            Our Preview Card
            </div>
        </div>
    </div>

</div>

{% endblock content %}

As usual, we will would do things just with Django, and when we are confident everything works, we will switch over to using HTMX.

Now, if you navigate to "localhost:8000/create", you should see this.

Now, that our GET request works as expected, let's go ahead and put a HTMX link in the navbar to get to it.

<!-- in navbar.html -->
<!-- nothing else changes. -->
{% if user.is_authenticated %}
        <li 
        class="nav-item"
        hx-boost="true">
        <a class="nav-link" href= "{% url 'create' %}"> New Blog </a>
        </li>
        
   <!-- here is the small screen nav bar -->
    <div 
    class="dropdown-menu dropdown-menu-right w-200" 
    aria-labelledby="navbar-dropdown-toggle-btn-1"
    hx-boost="true"> <!--  new -->

    {% if user.is_authenticated %}
    <a class="nav-link" href= "{% url 'create' %}"> New Blog </a>

Then, we will handle our POST requests. Here is our updated views.py

from django.shortcuts import render, get_object_or_404, redirect

def create(request):
    form = BlogForm()
    if request.method == "POST":
        form = BlogForm(request.POST)
        if form.is_valid():
            blog = form.save()
            return redirect("create", slug= blog.slug)
        else:
            return render(request, "create_blog.html", {"form": form, "errors": form.errors})
    return render(request, "create_blog.html", {"form": form, "errors": None})

And, let's not forget handling our errors if we have any,

<!-- in create_blog.html -->
<div class="card" id="form">
            {% if errors %} {{errors}} {% endif %} <!-- new -->
            <form method = "post">
            {% csrf_token %}
                {{form|crispy}}
                <button class="btn"> Submit </button>
             </form>
</div>

Let's go ahead and test creating another blog.

Title: HTMX is awesome  Content: Hello, universe!

If everything worked, on submission you should be redirected to your blog.

Now, let's go ahead and change our form to use HTMX.

<form 
method= "post"
hx-post= "{% url 'create' %}"
hx-target = "#content"
>
  <!-- no longer need CSRF token -->
  {{form|crispy}}
  <button class="btn"> Submit </button>
</form>

Let's try another blog and see if it worked.

Title: Django is great  Content: Hello, World!

Simple, right?

Preview

Now, for the fun part. Adding our preview feature.

Here is what we want to do. We want to post our data to an endpoint whenever the user stops typing. Then, we want the response to that request to go inside the preview card. Our HTMX might look something like this.

<!--  a keydown event happens when a key is pressed down, and then keyup – when it’s released. To get when the user stops writing, we look for keyup changed, we also add a delay for slow typers. -->
 <textarea 
            hx-post="{% url 'preview' %}"
            hx-trigger= "keyup changed delay:500ms"
            hx-target: "#preview">
 </textarea>

We have a problem though. We are using Django forms, which means we don't have ready access to the individual elements of the form. One solution (that's is not very good), is to not use Django forms and render the form manually.

A better solution is to use the Django form widgets. Form widgets allow us to render our form elements with any attributes we want through attrs dictionary. Here is how we can change a Django modelform to render hx attributes.

# in forms.py
from django import forms
from .models import Blog


class BlogForm(forms.ModelForm):
    class Meta:
        model = Blog
        fields = ("title", "content")

        widgets = {
            "content": forms.Textarea(
                attrs={
                    "hx-post": "{% url 'preview' %}",
                    "hx-trigger": "keyup changed delay:500ms",
                    "hx-target": "#preview",
                }
            )
        }

Now, let's inspect our page source (right click, then view page source)  and see how Django rendered our form with crispy forms. You should see something like this in the form element.


<div id="div_id_title" class="form-group"> 
    <label for="id_title" class=" requiredField">
        Title<span class="asteriskField">*</span> 
    </label> 

    <div class=""> 
    <input type="text" 
    name="title" 
    maxlength="250" 
    class="textinput textInput form-control" required id="id_title"> 
    </div>
</div> 

<div id="div_id_content" class="form-group"> 
    <label for="id_content" class=" requiredField">
     Content<span class="asteriskField">*</span> 
     </label> 
     <!-- change here -->
     <div class=""> 
         <textarea name="content" cols="40" rows="10" 
         hx-post="{% url &#x27;preview&#x27; %}" 
         hx-trigger="keyup changed delay:500ms" 
         hx-target="#preview" 
         class="textarea form-control" required id="id_content">
         </textarea>
       </div> 
</div>

It sort of worked. Our form element attributes are rendered correctly, but our URL is broken. This is because when Django converts our modelform to HTML, whenever it sees quotes in a string (i.e, single quotes inside double quotes), it automatically converts them to Unicode.

The easiest fix is to refer to the URL by its path, instead of its name. Here is our updated form with the fix.

from django import forms
from .models import Blog


class BlogForm(forms.ModelForm):
    class Meta:
        model = Blog
        fields = ("title", "content")

        widgets = {
            "content": forms.Textarea(
                attrs={
                    "hx-post": "/preview", #new
                    "hx-trigger": "keyup changed delay:500ms",
                    "hx-target": "#preview",
                }
            )
        }

The downside to this fix is that if we ever change our path, we have to also change our form.

There is a more complicated solution by overriding how Django escapes quotes. But, I don't recommend it unless you really know what you are doing (the function to override is at django.utils.html.escape).

Also, if you are a more experienced developer -  "mark_safe" doesn't work. As the conversion from a URL name to a path breaks.

from django.utils.safestring import mark_safe 
 ...
 "hx-post": mark_safe("{% url 'preview' %})", #doesn't work

If you come up with a better, more elegant solution. Let me know!

Update: Thanks to user @gabriel_da_mota on Twitter and Martin Dion via email for suggesting to try and use reverse_lazy. The following solution is more elegant and allows us to use the URL naming Django pattern.

#reverse causes circurlar import erros
from django.urls import reverse_lazy

...
"hx-post": reverse_lazy("preview"),

Now, that we managed to render our textarea with HTMX attributes, let's build the endpoint and the view to handle it.

#in urls.py
from django.urls import path
from . import views

urlpatterns = [
    path("", views.home, name="home"),
    path("create", views.create, name="create"),
    path("preview", views.preview, name="preview"), #new
    path("blog/<slug:slug>", views.blog, name="blog"),
]
"""
if you change "preview" path, you must also change form attrs in blog/forms.py
"""

And here is the view,

from django.http import HttpResponse

def preview(request):
    content = request.POST["content"]
    return HttpResponse(content)

Notice that we didn't use a component here or the Django render to return the response. We didn't need to, we returned the content directly through the HttpResponse Django object, because that's all what we need.

Now, go ahead and write something like <h1> hello </h1>. You should see something like this.

This is not a very safe setup though. Go ahead and write something like

<script> alert("hello') </script>

You will see that your browser executed that code. A malicious actor can wreck havoc by executing javascript in our application without our control. In a production environment, we would need a package such as django-bleach or just bleach to sanitize our user input.

Now, our user story "On creating blogs, I want to see a preview of what my blog would look like" is done. Let's commit our code and move to the last user story. You can see the code up to this point here.

Markdown

The last user story "I want to use markdown to create my blog, and still see a preview" should be straightforward. We want to convert our incoming content from markdown to HTML.

To do this, we will download a third-party package "Markdown".

docker compose exec web pipenv install markdown

Then, since we updated our pipfile, let's reboot our docker container.

docker compose down
docker compose up -d --build

Now, let's use our new package to markdown our content.

# in views.py
import markdown

def preview(request):
    content = markdown.markdown(request.POST["content"])
    return HttpResponse(content)

Now, let's try writing markdown for our blog. You should see something like this.

Now, go ahead and click submit.

It didn't work, right? You see, whatever we have in our textarea, that is what will be saved in our database and be displayed as is.

We could fix this in two ways. One, we could make a template filter to display markdown as HTML. This is the approach that this tutorial took if you want to go this route.

Another, we could convert the markdown to HTML in our retrieve view. Both are completely valid. So, I will go ahead and use the second approach so you could see both ways.

To do this, we need to slightly change our retrieve view.

# in views.py
def blog(request, slug):
    blog = get_object_or_404(Blog, slug=slug)
    content = markdown.markdown(blog.content) #new
    return render(request, "blog.html", {"content": content, "title": blog.title}) #changed

Then, we need to change our blog template to accommodate the change we made to our view,

<!-- nothing else changes -->
<!-- blog.html -->
<div class="card w-lg-half w-full mx-auto">
        <h2 class= "card-title">{{title}} </h2>
        {{content|safe}}
        
    </div>

Now, let's try another blog with markdown. Did it work? Great! Our application is now done with all the user stories we specified in the beginning.

Let's commit our code. You can see my version here.

Note, if you try writing a code block via markdown, it will work but you still need to highlight it or style with CSS +/- Javascript. This is a relatively straightforward JS library that does that.

Conclusion

In this tutorial, we built a blog with HTMX and Django. We learned about hx-boost as well as using Django modelforms and the attrs dictionary to add HTMX attributes.

This is a part of a longer course where we build eight single-page applications using Django & HTMX. If you are interested in knowing when future tutorials come out, please subscribe below.

Get notified about new HTMX/Django tutorials
You will only get an email whenever a post or a course is published!

Copyright © HTMX-Django 2021.

Built by Jonathan Adly. Contact for opportunities.