Skip to main content

New Site (same as old site)

You're looking at the new seancoates.com.

"I'm going to pay attention to my blog" posts on blogs are… passé, but…

I moved this site to a static site generator a few years ago when I had to move some server stuff around, and had let it decay. I spent most of the past week of evenings and weekend updating to what you see now.

It's still built on Nikola, but now the current version.

I completely reworked the HTML and CSS. Turns out—after not touching it in earnest in probably a decade (‼️)—that CSS is a much more pleasant experience these days. Lately, I've been doing almost exclusively back-end and server/operations work, so it was actually a bit refreshing to see how far CSS has come along. Last time I did this kind of thing, nothing seemed to work—or if it did work, it didn't work the same across browsers. This time, I used Nikola's SCSS builder and actually got some things done, including passing the Accessibility tests (for new posts, anyway) in Lighthouse (one of the few reasons I fire up Chrome), and a small amount of Responsive Web Design to make some elements reflow on small screens. When we built the HTML for the previous site, so long ago, small screens were barely a thing, and neither were wide browsers for the most part.

From templates that I built, Nikola generates static HTML, which has a few limitations when it comes to serving requests. The canonical URL for this post is https://seancoates.com/blogs/new-site-same-as-old-site. Note the lack of trailing slash. There are ways to accomplish this directly with where I wanted to store this generated HTML + assets (on S3), but it's always janky. I've been storing static sites on S3 and serving them up through CloudFront for what must be 7+ years, now, and it works great as long as you don't want to do anything "fancy" like redirects. You just have to name your files in a clever way, and be sure to set the metadata's Content-Type correctly. The file you're reading right now comes from a .md file that is compiled into [output]/blogs/new-site-same-as-old-site/index.html. Dealing with the "directory" path, and index.html are a pain, so I knew I wanted to serve it through a very thin HTTP handling app.

At work, we deploy mostly on AWS (API Gateway and Lambda, via some bespoke tooling, a forked and customized runtime from Zappa, and SAM for packaging), but all of that seemed too heavy for what amounts to a static site with a slightly-more-intelligent HTTP handler. Chalice had been on my radar for quite a while now, and this seemed like the perfect opportunity to try it.

It has a few limitations, such as horrific 404s, and I couldn't get binary serving to work (but I don't need it, since I put the very few binary assets on a different CloudFront + S3 distribution), but all of that considered, it's pretty nice.

Here's the entire [current version of] app.py that serves this site:

 import functools

 from chalice import Chalice, Response
 import boto3


 app = Chalice(app_name="seancoates")
 s3 = boto3.client("s3")
 BUCKET = "seancoates-site-content"

 REDIRECT_HOSTS = ["www.seancoates.com"]


 def fetch_from_s3(path):
     k = f"output/{path}"
     obj = s3.get_object(Bucket=BUCKET, Key=k)
     return obj["Body"].read()


 def wrapped_s3(path, content_type="text/html; charset=utf-8"):
     if app.current_request.headers.get("Host") in REDIRECT_HOSTS:
         return redirect("https://seancoates.com/")

     try:
         data = fetch_from_s3(path)
         return Response(
             body=data, headers={"Content-Type": content_type}, status_code=200,
         )
     except s3.exceptions.NoSuchKey:
         return Response(
             body="404 not found.",
             headers={"Content-Type": "text/plain"},
             status_code=404,
         )


 def check_slash(handler):
     @functools.wraps(handler)
     def slash_wrapper(*args, **kwargs):
         path = app.current_request.context["path"]
         if path[-1] == "/":
             return redirect(path[0:-1])
         return handler(*args, **kwargs)

     return slash_wrapper


 def redirect(path, status_code=303):
     return Response(
         body="Redirecting.",
         headers={"Content-Type": "text/plain", "Location": path},
         status_code=status_code,
     )


 @app.route("/")
 def index():
     return wrapped_s3("index.html")


 @app.route("/assets/css/{filename}")
 def assets_css(filename):
     return wrapped_s3(f"assets/css/{filename}", "text/css")


 @app.route("/blogs/{slug}")
 @check_slash
 def blogs_slug(slug):
     return wrapped_s3(f"blogs/{slug}/index.html")


 @app.route("/brews")
 @app.route("/shares")
 @app.route("/is")
 @check_slash
 def pages():
     return wrapped_s3(f"{app.current_request.context['path'].lstrip('/')}/index.html")


 @app.route("/archive")
 @app.route("/blogs")
 def no_page():
     return redirect("/")


 @app.route("/archive/{archive_page}")
 @check_slash
 def archive(archive_page):
     return wrapped_s3(f"archive/{archive_page}/index.html")


 @app.route("/rss.xml")
 def rss():
     return wrapped_s3("rss.xml", "application/xml")


 @app.route("/assets/xml/rss.xsl")
 def rss_xsl():
     return wrapped_s3("assets/xml/rss.xsl", "application/xml")


 @app.route("/feed.atom")
 def atom():
     return wrapped_s3("feed.atom", "application/atom+xml")

Not bad for less than 100 lines (if you don't count the mandated whitespace, at least).

Chalice handles the API Gateway, Custom Domain Name, permissions granting (for S3 access, via IAM policy) and deployments. It's pretty slick. I provided DNS and a certificate ARN from Certificate Manager.

Last thing: I had to trick Nikola into serving "pretty URLs" without a trailing slash. It has two modes, basically: /blogs/post/ or /blogs/post/index.html. I want /blogs/post. Now, avoiding the trailing slash usually invokes a 30x HTTP redirect when the HTTPd that's serving your static files needs to add it so you get the directory (index). But in my case, I was handling HTTP a little more intelligently, so I didn't want it. You can see my app.py above handles the trailing slash redirects in the wrapped_s3 function, but to get Nikola to handle this in the RSS/Atom (I had control in the HTML templates, but not in the feeds), I had to trick it with some ugliness in conf.py:

# hacky hack hack monkeypatch
# strips / from the end of URLs
from nikola import post

post.Post._unpatched_permalink = post.Post.permalink
post.Post.permalink = lambda self, lang=None, absolute=False, extension=".html", query=None: self._unpatched_permalink(
    lang, absolute, extension, query
).rstrip("/")

I feel dirty about that part, but pretty great about the rest.