Copy this website

a11ce.com/website.html

This page describes how a11ce.com is constructed, with the goal of walking you through making your own similar site.

1. Hosting

This site is entirely static, so you just need somewhere to put html (etc) files. I use github pages.

First, make a new repo called <your-username>.github.io. This is a magic string that identifies the repo as a site attached to your username, so do this even if you're going to use a custom domain. Mine is private so I can have unlisted pages and be able to delete stuff. (ref)

Then, go to the repo settings, click pages, and set your publishing source as the docs/ folder in your main branch. (ref)

Now, anything you commit in the docs folder of the repo will show up on your site. For example, docs/index.html will be your homepage.

You can point a domain at your github pages site as described here.

This setup does not depend on github, and it works as long as your host lets you upload a static folder. Remember that github is not a file storage site and you shouldn't trust it to be one. Think of it as a write-only mirror of your local copy. You can also use gitlab pages, cloudflare pages, etc. If you want to follow this guide but avoid github, lmk and I can look into the exact changes required.

2. Markdown pages

The site generator is a makefile that operates on some specially-named folders. The first is md-src, which converts markdown to html using pandoc.

Write some markdown in md-src/index.md:

---
pagetitle: for the browser tab and search results and stuff
---

# markdown header

normal markdown. also you can write html directly in here and
it works. the stuff between the --- is called frontmatter,
it's a block of YAML that pandoc uses when generating the page.

Add this to your makefile:

# collect all markdown files in md-src,
# including in subdirectories
MDSOURCES :=  $(wildcard md-src/*.md) $(wildcard md-src/**/*.md)
# Map them to html files
HTMLPAGES := $(MDSOURCES:md-src/%.md=docs/%.html)

# -s creates a standalone page, 
# -smart disables smart typography
PANDOC_OPTS := -s -f markdown-smart

all: clean $(HTMLPAGES)
.PHONY: all clean

docs/%.html: md-src/%.md
    @# ensure the parent directory of the output file exists
    @mkdir -p $(dir $@)
    @# markdown->html
    pandoc $(PANDOC_OPTS) $< -o $@

clean:
    rm -r docs
    mkdir docs

Then install pandoc and make, and run make. Now you can open docs/index.html and see your homepage.

Note that the docs folder is deleted and regenerated from scratch every time you run make. Never make changes to files in docs.

3. CSS

The next rule we're going to add to the makefile copies the contents of the includes/ directory with no changes:

# replace these two lines
all: clean includes $(HTMLPAGES)
.PHONY: all clean includes

includes:
    cp -r includes/. docs

This is useful for many non-html files: If you put an image in includes/images/cat.png, you can use it in markdown with ![](images/cat.png). To use includes/style.css on a markdown page, add css: style.css as a line in the frontmatter. To include some javascript, add:

header-includes:
  - <script src="something.js"></script>

You can also use header-includes for 3rd-party scripts, favicons, RSS feed links, etc. The frontmatter for this page looks like:

---
pagetitle: Copy this website
header-includes:
  - <link rel="shortcut icon" type="image/x-icon" href="favicon.ico">
  - <script src="pastel-accent.js"></script>
  - <script data-collect-dnt="true" async src="https://scripts.simpleanalyticscdn.com/latest.js"></script>
  - <link rel="alternate" type="application/atom+xml" title="a11ce" href="https://a11ce.com/atom.xml">
  - <link rel="stylesheet" href="contact.css">
  - <script src="contact.js"></script>
css: article.css
---

I have all this stuff in a template.md that I copy when starting a new page.

If you don't know CSS, basically you just need to be aware that each rule is 'which things am I acting on' plus 'what am I doing with them', then you can google (or ask an LLM about) the rest. If you use an LLM to write your css, make sure to say things like "add a bit more space below the header" and not "make it look good". Pandoc is pretty good about using semantic html, so you can usually target element types.

That's it! Put stuff in md-src/page-name.md, run make, open the html in docs to preview it, then commit and push. You have a website!

If you have any trouble getting to this point, feel free to reach out.

4. Examples

The following sites are using (variants of) this setup:

5. Fancy stuff

My site has some features that go beyond what's described above. I'll talk about a few of them here.

Contact form

First, the contact form. It's a snippet of html that I have in my markdown template:

<div id="contact-box" style="margin-top: 0;">
<textarea id="contact-msg" placeholder="questions/comments/etc?"></textarea>
<button id="contact-send" onclick="sendContact()">send</button>
</div>

and a contact.js:

function sendContact() {
  const input = document.getElementById("contact-msg");
  const btn = document.getElementById("contact-send");
  const msg = input.value.trim();
  if (!msg) return;
  btn.disabled = true;
  btn.textContent = "sending...";
  fetch("https://a11ce--some-identifier.web.val.run", {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({ message: msg, source: location.pathname }),
  }).then(() => {
    btn.textContent = "sent!";
    input.value = "";
  }).catch(() => {
    btn.textContent = "error";
  }).finally(() => {
    setTimeout(() => { btn.textContent = "send"; btn.disabled = false; }, 2000);
  });
}

When the button is clicked, it sends the form contents to a val.town snippet that bounces it to a discord webhook.

export default async function (req: Request): Promise<Response> {
  if (req.method === "OPTIONS") {
    return new Response(null, {
      headers: {
        "Access-Control-Allow-Origin": "*",
        "Access-Control-Allow-Methods": "POST",
        "Access-Control-Allow-Headers": "Content-Type",
      },
    });
  }

  const { message, source } = await req.json();
  if (!message || typeof message !== "string") {
    return new Response("missing message", { status: 400 });
  }

  await fetch(Deno.env.get("DISCORD_WEBHOOK_URL"), {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({ content: message }),
  });

  return new Response("ok", {
    headers: { "Access-Control-Allow-Origin": "*" },
  });
}

I love discord webhooks, I use them all the time as a general notification channel. Remember that this site is static, but val.town lets you add backend-y features without a backend! Very neat.

Scribble

Ok I lied earlier, most of my pages are not actually written in markdown. Instead, I use scribble/text as a preprocessor.

If you add #lang scribble/text as a line before the frontmatter, and @(require scribble/html) after the frontmatter, you can write scribble functions that produce html and/or markdown, while also being able to write markdown, while also being able to write HTML directly. For example, at the top of the source for Generation I have

@(define (e) "<span style=\"font-family: serif; position: relative; top: -0.05em;\">⊙</span>")

so I can write @(e) and it gets rendered correctly. For How To Talk To Computers, I have some color highlight macros:

@(define (cy . text)
  (string-append "<span class=\"hl-cyan\">" (apply string-append text) "</span>"))

plus some other highlighting macros that parse annotations, and a binary tree SVG renderer.

In general, Scribble functions are useful for automating repetitive or tedious formatting stuff.

For this to work, add the following to your makefile and put .scrbl files in scrbl-src:

# collect all scribble files in scrbl-src
SCRBLSOURCES := $(wildcard scrbl-src/*.scrbl) $(wildcard scrbl-src/**/*.scrbl)
# map them to html files
SMDHTML := $(SCRBLSOURCES:scrbl-src/%.scrbl=docs/%.html)

# add $(SMDHTML) to all
all: clean includes $(HTMLPAGES) $(SMDHTML)

docs/%.html: scrbl-src/%.scrbl
    @# ensure the parent directory of the output file exists
    @mkdir -p $(dir $@)
    @# run scribble to produce a temporary markdown file
    racket $< > tmp.md
    @# then convert that markdown->html
    pandoc $(PANDOC_OPTS) tmp.md -o $@
    @# and remove the temp file
    @rm tmp.md

You can use this pattern for other preprocessors, like latex or typst, with or without the intermediate markdown step. As long as the rule maps files in a source directory to html files in docs/ it will work.

For reference, my actual template.scrbl looks like this:

#lang scribble/text
---
pagetitle: TITLE
header-includes:
  - <link rel="shortcut icon" type="image/x-icon" href="favicon.ico">
  - <script src="pastel-accent.js"></script>
  - <script data-collect-dnt="true" async src="https://scripts.simpleanalyticscdn.com/latest.js"></script>
  - <link rel="alternate" type="application/atom+xml" title="a11ce" href="https://a11ce.com/atom.xml">
  - <link rel="stylesheet" href="contact.css">
  - <script src="contact.js"></script>
css:
  - article.css
  - codeblock.css
---

@(require scribble/html)

# TITLE

> [a11ce.com](https://a11ce.com)/SLUG.html

BODY

---

<div id="contact-box">
<textarea id="contact-msg" placeholder="questions/comments/etc?"></textarea>
<button id="contact-send" onclick="sendContact()">
send
</button>
</div>

Thanks for reading! Curious about another feature? Used this guide for your site? Let me know below.