Continuing with a comment system for my static blog. The client side experience, with and without Javascript.

DIY Comments - Step 3

1,468 words.

DIY Comments - Step 3

The continuing adventures of adding homegrown DIY comments to my static blog.

Last we left off, we’d checked for spam, and written comments to an AWS DynamoDB table. This time, we’re making some big changes. No development plan survives real-world usage.

A New No-Javascript Solution

I had always assumed browsing the Internet without Javascript was a mythical tale told to frighten developers, but Bhagpuss recently reminded me that no-Javascript users could be lurking anywhere among us, leaping out of the shadows to shock us when we least expect it.

(But seriously, I might turn off Javascript more myself, but a lot of the sites I have open in tabs all the time are very Javascript-heavy.)

So I decided to reprioritize no-Javascript comments and thought about how to improve that experience.

I abandoned what I had originally planned, which was to submit directly to the api from a comment form on the blog page.

The problems I was dealing with were these:

  • Without Javascript, it was impossible to display the comment you had entered on the blog post page (because it’s static), which felt weird and looked like the comment didn’t go through when it had.
  • I was having no end of difficulty trying to redirect back to the original blog post page after a comment was submitted by the browser to the api.
  • Setting up the api endpoint to handle both application/json and application/x-www-form-urlencoded submissions was pretty ugly.

So a better way popped into my head: Link to a separate page solely for entering comments without Javascript. It might be a bit counter-intuitive for readers used to WordPress-style commenting, but hopefully it won’t be too much of a burden.

With the page on a different subdomain (comments.endgameviable.com), I was no longer restricted by my static blog pages, and I could render dynamic HTML on the server side and show comments as they’re entered, and give a more interactive experience.

I did this with, you guessed it, another AWS Lambda serverless function and another API Gateway configuration. Why rent a VPS when I can put an entire HTML server implementation into a tiny little single-purpose serverless function?

(Incidentally, I haven’t yet looked at the cost of running all these Lambda functions. My assumption is it will be reasonable for my tiny little site. A brief glance at AWS Billing reveals that my API Gateway and Lambda and Dynamo usage so far in September are $0.)

This time I opted for Golang instead of Node.js. I was led to believe Golang has a faster startup time, which is perfect for serverless functions, where every millisecond counts. Counts against my monthly bill, that is.

The Lambda function renders an HTML page and processes GET and POST requests. It’s very old school. It reminded me of the ancient past world of PHP.

It looks something like this, stripped down to the bare essentials:

func lambdaHandlerWeb(ctx context.Context, request events.APIGatewayProxyRequest) (events.APIGatewayProxyResponse, error) {

    var data PageData
	if request.HTTPMethod == "GET" {
		data.Title = request.QueryStringParameters["title"]
		data.Origin = request.QueryStringParameters["origin"]
	} else if request.HTTPMethod == "POST" {
		values, _ := url.ParseQuery(request.Body)
		data.Title = values.Get("title")
		data.Page = values.Get("page")
		data.Origin = values.Get("origin")
		data.Name = values.Get("name")
		data.Email = values.Get("email")
		data.Comment = values.Get("comment")
		data.Honeypot = values.Get("website")
		data.ClientIP = request.RequestContext.Identity.SourceIP
		data.UserAgent = request.RequestContext.Identity.UserAgent
		data.Referrer = request.Headers["referer"] // lowercase
        // validate and save the comment in DynamoDB table
		saveComment(ctx, data)
	}

    // query DynamoDB for recent comments
	data.Comments = fetchComments(ctx, cfg, data.Page)

    // generate HTML page using a template with variable substitutions
	t := template.Must(template.New("webpage").Parse(htmlTemplate))
	html := renderTemplate(t, data)

    // return a response
	return events.APIGatewayProxyResponse{
		StatusCode: 200,
		Headers: map[string]string{
			"Content-Type": "text/html",
		},
		Body: html,
	}, nil
}

I’ll be converting all my previous Node.js Lambda functions into Golang. (It turns out ChatGPT can translate code from Node.js to Golang very easily.) I’ll be doing this so I can share code between the three Lambda functions I require so far.

Additionally, a separate page for no-Javascript comments could be a boon for me because it might also thwart the armies of naive spam bots out there. If there’s no Javascript, there won’t be a comment form to submit spam to on the blog page. The spam bot would have to follow the link to the comment page. Maybe it’s naive of me to think they can’t or won’t do that. I’m watching my logs carefully to see what the bots are doing during this whole development process. (I’ve received less spam than I expected, so far.)

I thought about embedding the no-Javascript comment page in the blog post with an iframe but I ran into a lot of formatting issues and abandoned that plan.

One issue I’ve noticed in my testing so far is that it’s pretty easy to accidentally submit a comment twice on the comment page. That’s a usage problem that goes all the way back to at least the early 2000s, back when it was easy to accidentally buy things twice on commerce sites. I’ll have to dig through the historical records to see how that issue was solved.

Meanwhile, Back in Javascript

For those running Javascript, we can build a better user experience by running clientside code to fetch comments and handle form submission right on the otherwise static blog page.

First, I removed the form from the basic HTML structure of the page. Then, I added the form with some Javascript at page startup. Here is a stripped down version of the essential Javascript for the blog page:

// add comment form to the page
function addForm() {
    const container = document.getElementById(containerId);
    const html = `
<form id="comment-form">
    <!-- all the form fields -->
</form>`;
    container.innerHTML = html;
}

// handle user clicking the form submit button
function handleSubmit(event) {
    event.preventDefault();

    // ...collect form fields
    // ...basic validation

    fetch(`${apiEndpoint}comment`, {
        method: 'POST',
        headers: {
            'Content-Type': 'application/json',
        },
        body: JSON.stringify(formData)
    });
    // ...error handling etc.

    fetchComments(); // only if successful
}

// get existing comments for the current blog post
async function fetchComments() {
    fetch(`${apiEndpoint}comments?page=${window.location.pathname}`)
    .then(response => response.json())
    .then(comments => {
        if (comments.length > 0) {
            displayComments(comments);
        }
    })
    // error handling etc.
}

// display comments by adding to the page
function displayComments() {
    const container = document.getElementById('comment-display-area');
    container.innerHTML = '';

    for (let i = 0; i < comments.length; i++) {
        const comment = comments[i];
        // a lot of:
        // - const element = document.createElement()
        // - x.appendChild(element)
    }
}

// called on page load
function startup() {
    // Adding the form with javascript means the form will not
    // appear if the user has javascript disabled,
    // and they can click the link to go to
    // the comment page instead.
    addForm();
    document.getElementById('comment-form').addEventListener('submit', handleSubmit);
    fetchComments();
}

document.addEventListener('DOMContentLoaded', startup);

A bit ugly to work with HTML in a Javascript file, unfortunately. Maybe there’s a cleaner way. I’ll deal with that later.

There is some work to be done for dealing with CORS issues in the API as well. Javascript fetches tend to complain loudly if the API responses don’t have the right Access-Control-* headers. I don’t fully understand the world of CORS yet, to be honest, because that came along after I last did extensive low-level web development work. (It seems rather pointless to me, because it’s only enforced by browsers, and is entirely bypassed if you make direct requests with, say, curl. So it has almost zero security benefit, if you ask me.)

There is also some CSS for styling the form on the page. I literally copied-and-pasted the results of a ChatGPT prompt like “make me some css for a comment form.” Have I mentioned how useful ChatGPT is for rapid prototype development? I don’t know if people are aware of this AI thing but people should check it out. It took me months to write a comment system back in the dark days of PHP, now I can get prototypes working in days. It’s taking me considerably longer to write blog posts about what I did than to do the actual thing.

Anyway, sharing that CSS with the separate no-Javascript comments.endgameviable.com page is a bit of a challenge, though, due to the nature of how Hugo combines and minifies CSS into one randomly-named file at build time. A problem for another time.

That’s it for now. Future steps include:

  • Importing my blog comment history into local data files inside my Hugo content directory (done)
  • Build-time Hugo templates to query local comment data archives to render older comments as static HTML (done)
  • Notifying me when comments are received (done)
  • Adding everything to a repo on GitHub for transparency
  • Administrative tools so I can manage comments better
  • Confirmations of cookie or local storage usage
  • Support for third-party authentication like Google if you want to
  • Maybe a way to export your own comments
  • The list goes on and on, which is why nobody should write their own comment system

Related

This is a homegrown DIY comment system I'm working on. It technically works but it hasn't been through extensive testing yet. Good luck. Go here to enter a comment on this post without Javascript.