Meerific

README.txt

How this website works; a tale of yak-shaving, over-engineering, and pursuing perfection.

Meerific is a web application for publishing (and selling) static content. It’s not quite a content management system (CMS); its only visual interface to your content is a couple buttons. It’s not quite a static site generator; although it generates HTML out of lightweight markup, it runs on a server, uses a database, and includes and a number of dynamic features that are much easier to implement with these tools. The main selling point of the site, I think, is that I only have to upload my content and some metadata to a cloud storage service, like Dropbox or S3, and the website takes care of the rest.

Why?

I started this project as a replacement for Darkroom, which I used to sell photo prints to friends and family. Unfortunately, the free tier of Darkroom has a limit of 100 total photos. The paid tier costs $15/month and increases the limit to…150 total photos. I don’t have many sales, so I decided to try running the operation myself: build a site to host the photos, hook up a payment system, and whenever an order comes in, print out the photo at my local lab and send it along. Meerific is that site.

Design Goals

Because this is my website, I want it to reflect my ethos, which I hope comes across in the goals below:

  • No interface, just files: I’m a big admirer of Blot, a one-man project that takes a cloud folder and turns it into a website. I like this idea because creating content becomes the sole focus. You can work with your favorite tools, add your own metadata to those files, and publish updates whenever you save a file. Plus, because you own your content and the folders that store it, you can easily migrate to another publishing system. Meerific is an attempt to recreate the core of Blot, except for a sellable gallery of images.
  • Fast, but personal: I want the site to load quickly. I don’t care for ad revenue or SEO-friendliness, as I plan to spread word of this site by word of mouth. I do appreciate little personal “delightful” features that make the user experience more pleasant, but not to the point where the site is inaccessbile. Thus, Meerific will have a little JavaScript. Any custom fonts are self-hosted. Use a CDN for images.
  • Simple: I really like the design of Field Mag. It feels both outdoorsy and techy, which pretty much describes me, so I’ve stolen several aspects of that design and used it for Meerific. I will likely update this design over time.

Additional Project Goals

  • Open source: I support open source software. Any projects I use and modify to work with this site will receive upstream contributions. This project has been open-sourced under a permissive license since, again, the content is all that matters. The code is available on Codeberg.

Blog Posts

Much like Blot, making a post is very simple once you’ve gotten everything set up. Create a markup file, add your content, and save it to the correct place.

Blot turns your folder into a website. Files become posts on your site. Drag and drop to publish.

Folder Structure and File Formats

Content is written in Djot, a lightweight markup language. I chose Djot because it has one specification as well as a parser available in my language of choice, Elixir.

Posts have this structure:

hello-world/
┣ index.dj
┗ metadata.json

The name of the folder is the path at which the content will be available on the website, e.g. “https://meerific.com/posts/hello-world”

index.dj contains the content, while metadata.json contains metadata about that content, for example:

{
  "title": "README.txt",
  "description": "How this website works; a tale of yak-shaving, over-engineering, and pursuing perfection.",
  "author": "Derek",
  "tags": ["software", "elixir"],
  "cover_photo": "/header.jpg",
  "toc": true
}

I put metadata in a separate file because Djot currently does not have a way to specify metadata (There is an open discussion about how best to do this).

Handling Changes

These folders are saved in an application-specific Dropbox folder. Dropbox created this folder when I created an App through the Dropbox App Console. Since it only needs to connect to my account, there’s no need to make it publicly available.

When I make any changes to these files, Dropbox notifies my website that there have been changes through a webhook. I pull down the changes through their API, then sync my application’s state with these changes.

I store the raw content, various dropbox-related metadata like the content hash and file ID, and post metadata in my Postgres database. This will allow me to search through content later.

I also parse the raw content into HTML and store it in CubDB, an embedded database. I do this because the raw content -> HTML may take a while, especially with potentially expensive operations like image processing, and it only needs to happen once per sync. I don’t see a need to store it in Postgres, since that feels a bit redundant to me. I don’t need to search the HTML content, since I already save the raw content for searching.

Then, when users go to /posts/hello-world, I pull the HTML content from CubDB and serve it in a LiveView.

Image processing

Similar to photos, I download post images from the internet (or my CDN), convert them to thumbnails, and compute their ThumbHashes. These hashes, when decoded into a binary, represent a decent image placeholder while the image downloads. I do this download / conversion in parallel, then save the hashes to CubDB so I don’t need to re-download any images.

Images which are saved to the CDN have a href that starts with /. The processor assumes these images are located under the /posts/:post_slug path on the CDN. Other images use a full URL.

Photos

I wanted photos to work similarly to posts: put a photo in cloud storage and it processes the image for you and syncs everything to your site. I am still working out the most appropriate file formats and widths, though, so I do this processing manually for now.

Preprocessing + Folder Structure

I have a script to take a full-width, full resolution JPEG image and a metadata.json file and convert them to files with the following structure:

photo-title-slug/
┣ 480.avif
┣ 640.avif
┣ ...
┣ 1280.avif
┗ thumb.jpeg

Each .avif file is the image with a different width. thumb.jpeg is 100 pixels wide, so ThumbHash can use it directly without any addtional processing.

This folder is uploaded to an S3-compatible cloud storage provider, in my case Backblaze B2.

Metadata

The above structure does not have any explicit metadata.json files because photos already have in-file metadata called EXIF. These include fields like image title, description, camera body model, lens, camera settings when the photo was taken, date/time when the photo was taken, and GPS location.

My pre-processing script combines the existing photo metadata in thumb.jpeg with the metadata in metadata.json with exiftool, then saves it back to thumb.jpeg.

Handling Changes

Although Backblaze does have webhooks support, I don’t use it yet. For now, I use my website’s admin controls to manually sync photos with cloud storage. All I do is list the subfolders in my bucket under the photos/ path; each result represents a new photo.

I then download the thumb.jpeg folder inside each folder and do the following:

  • convert the photo to a ThumbHash
  • read EXIF metadata using the exexif Elixir package (with a few modifications)
  • extract a few indexable fields from the image (like tags and photo number).
  • upload this record to the database as a new photo
  • use the parent path (photo-title-slug) as the desired slug prefix.

Photo URLs

Photo URLs are a bit more complicated than Post URLs:

  • It’s reasonable to expect photo URLs to have the same title and slug. For exmaple, two photos taken of the same subject at different angles
  • each photo may have different aspect ratios / crops, e.g. 2:3, 3:2, 1:1, available to purchase

To support these case, I plan to use the following standard:

  • for alternative angles, append an integer to the slug, e.g. photo-title-slug-2
  • for alternative aspect ratios, put the aspect ratio in the title, e.g. photo-title-slug-1x1 or photo-title-slug-1x1-2. These photos should still have the same Image Number metadata, so when pulling the image(s) from the database to show, group the images by Image Number and only show the first one. Then, pull the available aspect ratios from these “relative” photos.

I use the photo’s primary key and appended integer (if present) to generate a sqid which I append to the photo slug, e.g. photo-title-slug-Lqj8a0. When users go to this path, the database decodes the slug and retrieves the desired photo(s).


I plan to add more content to this document in the future:

  • design decisions
  • feature overviews (if you don’t want to read the source code)
  • other project goals

Stay tuned!


Whenever I’ve started personal websites in the past, I’ve eventually taken them down because I find they don’t add value to my life, and I don’t want to go through the effort of maintaining them.

I think this one will be different because it’s not just a personal website: it is where I tell people to go when they want to buy my photos in print form. I hope to cultivate it as long as I can!

Derek