diff --git a/README.md b/README.md
index 53baf11..5b06add 100644
--- a/README.md
+++ b/README.md
@@ -56,9 +56,13 @@
$ make build
+### build a single file
+
+ $ make build file=index.html
+
### start dev server
- $ make
+ $ make serve
Visit `localhost:8080` in web browser
diff --git a/html/.gitignore b/html/.gitignore
index 628bfae..8d3b786 100644
--- a/html/.gitignore
+++ b/html/.gitignore
@@ -15,4 +15,5 @@ posts/resize-a-qemu-disk-image.html
posts/set-up-a-gitweb-server.html
posts/start-erlangs-dialyzer-with-gui-from-a-docker-container.html
posts/test-mix-task-file-modify.html
+posts/test-nginx-conf-directives.html
pygments.css
\ No newline at end of file
diff --git a/html/images/openresty-default-index-page.png b/html/images/openresty-default-index-page.png
new file mode 100644
index 0000000..3705222
Binary files /dev/null and b/html/images/openresty-default-index-page.png differ
diff --git a/posts/2025-06-30-test-nginx-conf-directives.md b/posts/2025-06-30-test-nginx-conf-directives.md
new file mode 100644
index 0000000..2f02278
--- /dev/null
+++ b/posts/2025-06-30-test-nginx-conf-directives.md
@@ -0,0 +1,566 @@
+{
+ title: "Test nginx Configuration Directives"
+ blurb: "We use MoonScript and some Lua packages to write tests for the
+ directives in our `nginx` configuration files."
+}
+$index
+
+## Introduction
+
+[`nginx`](https://docs.nginx.com/nginx/admin-guide/web-server/web-server/#rewrite-uris-in-requests)
+configuration can contain any number of important directives (redirects and
+rewrites, for example) that need to be verified for correctness. We can write
+tests for directives and run them against a test server to ensure they are
+correct.
+
+To do this, we'll use...
+
+- [MoonScript](https://moonscript.org) and (by extension) [Lua](https://www.lua.org/) programming languages
+- `nginx` we'll get from [OpenResty](https://openresty.org/en/), a web platform
+created by Chinese developer, [Yichun Zhang](https://agentzh.org/)
+- the [Busted testing framework](https://lunarmodules.github.io/busted/)
+- the Lua package manager, [LuaRocks](https://luarocks.org/)
+- a fantastic little library, [`luajit-curl`](https://bitbucket.org/senanetworksinc/luajit-curl/src/master/),
+from Japanese developer [SENA Networks, Inc](https://www.sena-networks.co.jp)
+- another great library, written by volunteers, [LuaSocket](https://github.com/lunarmodules/luasocket)
+- our favorite container manager, [Docker Engine](https://docs.docker.com/engine/)
+
+## Setup
+
+Since we require LuaRocks, we'll use a Buildpack tag, which comes with it
+already installed.
+
+```console
+$ docker pull openresty/openresty:bookworm-buildpack
+```
+
+Start a server on `localhost`:
+
+```console
+$ docker run --rm -it -p 80:80 openresty/openresty:bookworm-buildpack
+```
+
+We can visit `localhost` in our browser and we should see the OpenResty splash
+page.
+
+
+
+## Get `nginx` running
+
+First, let's [prepare the directory layout](https://openresty.org/en/getting-started.html#prepare-directory-layout).
+
+```console
+$ mkdir -p logs/ conf/conf.d/ html/
+```
+
+Next, we copy over [the default `nginx` config file](https://github.com/openresty/docker-openresty?tab=readme-ov-file#nginx-config-files).
+
+```console
+$ docker run --rm -it -w /opt -v $PWD:/opt openresty/openresty:bookworm-buildpack \
+cp /etc/nginx/conf.d/default.conf /opt/conf.d/
+```
+
+Then, we update the root directive in `default.conf`:
+
+::: filename-for-code-block
+`conf/conf.d/default.conf`
+:::
+
+```diff
+ location / {
+- root /usr/local/openresty/nginx/html;
++ root /var/www;
+ index index.html index.htm;
+```
+
+Now, let's add an index file.
+
+::: filename-for-code-block
+`html/index.html`
+:::
+
+```html
+
+
+
+
+
+
+
+
+ hello world!
+
+
+```
+
+Last, we start `nginx`:
+
+```console
+$ docker run --rm -it -p 80:80 \
+-v $PWD/conf/conf.d:/etc/nginx/conf.d -v $PWD/html:/var/www \
+openresty/openresty:bookworm-buildpack
+```
+
+Then, in another console, this should output our index file.
+
+```console
+$ curl -v localhost
+* Trying 127.0.0.1:80...
+* Connected to localhost (127.0.0.1) port 80 (#0)
+> GET / HTTP/1.1
+> Host: localhost
+> User-Agent: curl/7.88.1
+> Accept: */*
+>
+< HTTP/1.1 200 OK
+< Server: openresty/1.27.1.2
+< ...
+<
+
+
+ ...
+
+ hello world!
+
+
+```
+
+## Test an HTTP request
+
+If we want to write a test for that request, we need some packages from
+LuaRocks. Let's add a `Dockerfile` to build an image with those packages
+installed.
+
+### Add a `Dockerfile`
+
+```Dockerfile
+FROM openresty/openresty:bookworm-buildpack
+
+WORKDIR /opt/app
+
+RUN luarocks install moonscript
+RUN luarocks install busted
+RUN luarocks install luajit-curl
+RUN luarocks install luasocket
+```
+
+Now let's build our image:
+
+```console
+$ docker build -t test-nginx .
+```
+
+### Write the test
+
+Let's first make a new directory where our tests will live.
+
+```console
+$ mkdir spec
+```
+
+Our test makes a cURL request against our test server:
+
+::: filename-for-code-block
+`spec/nginx_spec.moon`
+:::
+
+```moonscript
+http = require "luajit-curl-helper.http"
+
+req = (url) ->
+ request = http.init url
+ st = request\perform!
+ error request\lastError! if not st
+ request
+
+describe "http://localhost", ->
+ it "sends /index.html", ->
+ request = req "http://localhost"
+ assert.same request\statusCode!, 200
+ assert.same request\statusMessage!, "OK"
+ assert.same request\body!\match("%s+(.-)%s+"), "hello world!"
+```
+
+### Run the test suite
+
+Start the test server. We're going to use `text-nginx`, the image we just
+built.
+
+```console
+$ ct=$(docker run --rm -d \
+-v $PWD/conf/conf.d:/etc/nginx/conf.d \
+-v $PWD/html:/var/www \
+-v $PWD:/opt/app \
+test-nginx)
+```
+
+Start the test run:
+
+```console
+$ docker exec -t $ct busted
+●
+1 success / 0 failures / 0 errors / 0 pending : 0.008246 seconds
+```
+
+Stop the test server.
+
+```console
+$ docker exec $ct openresty -s stop
+```
+
+## Create a `Makefile`
+
+We now have a number of long `docker` commands, let's create a `Makefile`
+to make running them easier.
+
+::: filename-for-code-block
+`Makefile`
+:::
+
+```Makefile
+image = test-nginx
+
+image-build:
+ docker build -t $(image) .
+
+image-rm:
+ docker image rm $(image)
+
+test:
+ @ct=$(shell docker run --rm -d \
+ -v $(PWD)/conf/conf.d:/etc/nginx/conf.d \
+ -v $(PWD)/html:/var/www \
+ -v $(PWD):/opt/app \
+ $(image)); \
+ docker exec -t $$ct busted; \
+ docker exec $$ct openresty -s stop
+```
+
+Now we can run the test suite with the command `make test`.
+
+## Configure the domain name
+
+Instead of `localhost` we'd like to use an actual domain name. We can do this
+with the `--add-host` option. But before we do that, we want to make sure our
+container does not have access to the internet, otherwise we might
+unintentionally get a response from a domain's server on the internet rather
+than from our test server.
+
+### Ensure the test container is offline
+
+We need to create a network that has no external access.
+
+```console
+$ docker network create --internal no-internet
+```
+
+Now we need to update our `Makefile` to add the test container to our
+internal-only network:
+
+```diff
+ test:
+ @ct=$(shell docker run --rm -d \
+ -v $(PWD)/conf/conf.d:/etc/nginx/conf.d \
+ -v $(PWD)/html:/var/www \
+ -v $(PWD):/opt/app \
++ --network no-internet \
+ $(image)); \
+```
+
+And now let's add a test in `spec/nginx_spec.moon` to make sure our test
+environment is offline:
+
+```moonscript
+describe "test environment", ->
+ it "can't connect to the internet", ->
+ assert.has_error (-> req "http://example.org"),
+ "Couldn't resolve host name"
+```
+
+Let's run our tests:
+
+```console
+$ make test
+●●
+2 successes / 0 failures / 0 errors / 0 pending : 0.020207 seconds
+```
+
+### Replace `localhost` with a custom domain
+
+To use a custom domain name instead of `localhost`, we will need to use the
+`--add-host` option for the `docker run` command. Again, we edit `Makefile`:
+
+```diff
+ test:
+ @ct=$(shell docker run --rm -d \
+ -v $(PWD)/conf/conf.d:/etc/nginx/conf.d \
+ -v $(PWD)/html:/var/www \
+ -v $(PWD):/opt/app \
+ --network no-internet \
++ --add-host=domain.abc=127.0.0.1 \
+ $(image)); \
+```
+
+Let's update our test to use the custom domain name:
+
+::: filename-for-code-block
+`spec/nginx_spec.moon`
+:::
+
+```diff
+-describe "http://localhost", ->
++describe "http://domain.abc", ->
+ it "sends /index.html", ->
+- request = req "http://localhost"
++ request = req "http://domain.abc"
+ assert.same request\statusCode!, 200
+```
+
+Verify our tests still pass.
+
+```console
+$ make test
+●●
+2 successes / 0 failures / 0 errors / 0 pending : 0.0224 seconds
+```
+
+## Test an HTTP redirect
+
+We want our server to redirect all `http` requests to `https`.
+
+### Write the test
+
+Let's practice a bit of test-driven development and write our test first.
+
+```moonscript
+describe "http://domain.abc", ->
+ it "redirects to https", ->
+ request = req "http://domain.abc"
+ assert.same request\statusCode!, 301
+ assert.same request\statusMessage!, "Moved Permanently"
+ assert.same request\header!.Location, "https://domain.abc/"
+```
+
+We should now have one failing test.
+
+```console
+$ make test
+●●◼
+2 successes / 1 failure / 0 errors / 0 pending : 0.010449 seconds
+
+Failure → .../luajit/lib/luarocks/rocks-5.1/busted/2.2.0-1/bin/busted @ 3
+http://domain.abc redirects to https
+spec/nginx_spec.moon:24: Expected objects to be the same.
+Passed in:
+(number) 301
+Expected:
+(number) 200
+```
+
+### Configure `nginx`
+
+We're going to add the redirect directives, as well as a server name for our
+domain and the directives for the SSL certificates we will generate.
+
+```diff
++server {
++ listen 80;
++ return 301 https://$host$request_uri;
++}
+
+ server {
+- listen 80;
++ listen 443 ssl;
++ server_name domain.abc;
++ ssl_certificate /etc/ssl/certs/domain.abc.pem;
++ ssl_certificate_key /etc/ssl/private/domain.abc.pem;
+
+ location / {
+ root /var/www;
+ index index.html index.htm;
+ }
+```
+
+### Generate self-signed SSL/TLS certs for testing
+
+Add a command to our `Dockerfile` to generate self-signed certificates:
+
+```Dockerfile
+RUN openssl req -x509 -newkey rsa:4096 -nodes \
+ -keyout /etc/ssl/private/domain.abc.pem \
+ -out /etc/ssl/certs/domain.abc.pem \
+ -sha256 -days 365 -subj '/CN=domain.abc' \
+ -addext "subjectAltName=DNS:domain.abc"
+```
+
+Rebuild the image:
+
+```console
+$ make image-rm image-build
+```
+
+We need to update our previous test to use HTTPS instead of HTTP.
+
+::: filename-for-code-block
+`spec/nginx_spec.moon`
+:::
+
+```diff
+-describe "http://domain.abc", ->
++describe "https://domain.abc", ->
+ it "sends /index.html", ->
+- request = req "http://domain.abc"
++ request = req "https://domain.abc"
+```
+
+Run tests:
+
+```console
+$ make test
+●●●
+3 successes / 0 failures / 0 errors / 0 pending : 0.017065 seconds
+```
+
+## Test reverse proxy a subdomain request to a Unix socket
+
+Let's say we have a running service that connects to a Unix socket. We want to
+proxy the requests through `nginx` so that our service can respond to `https`
+requests but can leave handling SSL/TLS to `nginx`.
+
+### Configure `nginx`
+
+We'll add another server block to `conf/conf.d/default.conf` for our subdomain,
+`git.domain.abc`, with the proxy directives:
+
+```nginx
+server {
+ listen 443 ssl;
+ server_name git.domain.abc;
+
+ location / {
+ client_max_body_size 1024M;
+ proxy_pass http://unix:/run/gitea/gitea.socket;
+ proxy_set_header Connection $http_connection;
+ proxy_set_header Upgrade $http_upgrade;
+ proxy_set_header Host $host;
+ proxy_set_header X-Real-IP $remote_addr;
+ proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
+ proxy_set_header X-Forwarded-Proto $scheme;
+ }
+}
+```
+
+### Add subdomain to SSL/TLS certs
+
+Next, we need to add our subdomain to the generated SSL certs in the
+`Dockerfile`:
+
+```diff
+ RUN openssl req -x509 -newkey rsa:4096 -nodes \
+ -keyout /etc/ssl/private/domain.abc.pem \
+ -out /etc/ssl/certs/domain.abc.pem \
+ -sha256 -days 365 -subj '/CN=domain.abc' \
+- -addext "subjectAltName=DNS:domain.abc"
++ -addext "subjectAltName=DNS:domain.abc,DNS:git.domain.abc"
+```
+
+### Add subdomain as a host
+
+Let's assign the loopback address to a variable and then add our subdomain as a
+host in our `Makefile`:
+
+```diff
++loopback = 127.0.0.1
+
+ test:
+ @ct=$(shell docker run --rm -d \
+ -v $(PWD)/conf/conf.d:/etc/nginx/conf.d \
+ -v $(PWD)/html:/var/www \
+ -v $(PWD):/opt/app \
+ --network no-internet \
+- --add-host=domain.abc=127.0.0.1 \
++ --add-host=domain.abc=$(loopback) \
++ --add-host=git.domain.abc=$(loopback) \
+ $(image)); \
+```
+
+
+### Add a test socket server
+
+We need to start up a mock socket server for our test to ensure our request is
+being proxied correctly. This is why we needed the LuaSocket library.
+
+Copied and modified from [here](https://github.com/lunarmodules/luasocket/blob/4844a48fbf76b0400fd7b7e4d15d244484019df1/test/unixstreamsrvr.lua),
+this should suit our purposes:
+
+::: filename-for-code-block
+`spec/unixstreamsrvr.moon`
+:::
+
+```moonscript
+socket = require "socket"
+socket.unix = require "socket.unix"
+u = assert socket.unix.stream!
+assert u\bind "/run/gitea/gitea.socket"
+assert u\listen!
+assert u\settimeout 1
+c = assert u\accept!
+
+while true
+ m = assert c\receive!
+ break if m == ""
+ print m
+```
+
+### Write the test
+
+And now we can add our test:
+
+::: filename-for-code-block
+`spec/nginx_spec.moon`
+:::
+
+```moonscript
+describe "https://git.domain.abc", ->
+ it "reverse-proxy's a subdomain request to a unix socket", ->
+ socket = fname: "unixstreamsrvr.moon", dir: "/run/gitea", owner: "nobody"
+ basepath = debug.getinfo(1).short_src\match"^(.*)/[^/]*$" or "."
+ seconds = 0.1
+
+ os.execute "install -o #{socket.owner} -d #{socket.dir}"
+ cmd = "su -s /bin/bash -c 'moon %s' %s"
+ server = io.popen cmd\format "#{basepath}/#{socket.fname}", socket.owner
+ os.execute "sleep #{seconds}" -- wait for server to start
+ f = io.popen "find #{socket.dir} -type s -ls", "r"
+ result = with f\read "*a"
+ f\close!
+ assert.truthy result\match "nobody%s+nogroup.+#{socket.dir}/gitea.socket"
+
+ req "https://git.domain.abc"
+
+ reqheader = with server\read "*a"
+ server\close!
+
+ assert.truthy reqheader\match "Host: git.domain.abc"
+```
+
+Because we modified the `Dockerfile`, we need to rebuild our image:
+
+```console
+$ make image-rm image-build
+```
+
+And if all went well, our test should pass.
+
+```console
+$ make test
+●●●●
+4 successes / 0 failures / 0 errors / 0 pending : 0.131619 seconds
+```
+
+## Conclusion
+
+These are just a few examples of how to test `nginx` directives. Using these
+tools, we can verify that changes to our server configuration are working the
+way we intended.
+
diff --git a/pygments.css b/pygments.css
index db6cb84..bc781fd 100644
--- a/pygments.css
+++ b/pygments.css
@@ -23,11 +23,11 @@ span.linenos.special { color: #50fa7b; background-color: #6272a4; padding-left:
.highlight .py-cpf { color: #6272a4 } /* Comment.PreprocFile */
.highlight .py-c1 { color: #6272a4 } /* Comment.Single */
.highlight .py-cs { color: #6272a4 } /* Comment.Special */
-.highlight .py-gd { color: #8b080b } /* Generic.Deleted */
+.highlight .py-gd { color: #ff5555 } /* Generic.Deleted */
.highlight .py-ge { color: #f8f8f2; text-decoration: underline } /* Generic.Emph */
.highlight .py-gr { color: #f8f8f2 } /* Generic.Error */
.highlight .py-gh { color: #f8f8f2; font-weight: bold } /* Generic.Heading */
-.highlight .py-gi { color: #f8f8f2; font-weight: bold } /* Generic.Inserted */
+.highlight .py-gi { color: #50fa7b } /* Generic.Inserted */
.highlight .py-go { color: #f8f8f2 } /* Generic.Output */
.highlight .py-gp { color: #50fa7b } /* Generic.Prompt */
.highlight .py-gs { color: #f8f8f2 } /* Generic.Strong */
diff --git a/renderers/markdown.moon b/renderers/markdown.moon
index 765ed9e..131ddad 100644
--- a/renderers/markdown.moon
+++ b/renderers/markdown.moon
@@ -1,5 +1,70 @@
Path = require "sitegen.path"
+dollar_temp = "z000sitegen_markdown00dollar0000"
+
+-- a constructor for quote delimited strings
+simple_string = (delim) ->
+ import P from require "lpeg"
+
+ inner = P("\\#{delim}") + "\\\\" + (1 - P delim)
+ inner = inner^0
+ P(delim) * inner * P(delim)
+
+lua_string = ->
+ import P, C, Cmt, Cb, Cg from require "lpeg"
+ check_lua_string = (str, pos, right, left) ->
+ #left == #right
+
+ string_open = P"[" * P"="^0 * "["
+ string_close = P"]" * P"="^0 * "]"
+
+ valid_close = Cmt C(string_close) * Cb"string_open", check_lua_string
+
+ Cg(string_open, "string_open") *
+ (1 - valid_close)^0 * string_close
+
+-- returns a pattern that parses a cosmo template. Can be used to have
+-- pre-processors ignore text that would be handled by cosmo
+parse_cosmo = ->
+ import P, R, Cmt, Cs, V from require "lpeg"
+ curly = P {
+ P"{" * (
+ simple_string("'") +
+ simple_string('"') +
+ lua_string! +
+ V(1) +
+ (P(1) - "}")
+ )^0 * P"}"
+ }
+
+ alphanum = R "az", "AZ", "09", "__"
+ P"$" * alphanum^1 * (curly)^-1
+
+escape_cosmo = (str) ->
+ escapes = {}
+ import P, R, Cmt, Cs, V from require "lpeg"
+
+ counter = 0
+
+ cosmo = parse_cosmo! / (tpl) ->
+ counter += 1
+ key = "#{dollar_temp}_#{counter}"
+ escapes[key] = tpl
+ key
+
+ patt = Cs (cosmo + P(1))^0 * P(-1)
+ str = patt\match(str) or str, escapes
+ str, escapes
+
+unescape_cosmo = (str, escapes) ->
+ import P, R, Cmt, Cs from require "lpeg"
+
+ escape_patt = P(dollar_temp) * P("_") * R("09")^1 / (key) ->
+ escapes[key] or error "bad key for unescape_cosmo"
+
+ patt = Cs (escape_patt + P(1))^0 * P(-1)
+ assert patt\match(str)
+
needs_shell_escape = (str) -> not not str\match "[^%w_-]"
shell_escape = (str) -> str\gsub "'", "''"
@@ -19,12 +84,13 @@ write_exec = (cmd, content) ->
fname
--- config command like this in site.moon:
--- require("renderers.markdown").cmd = "pandoc --mathjax >"
-class PandocRenderer extends require "sitegen.renderers.markdown"
- unescape_cosmo = @unescape_cosmo
- escape_cosmo = @escape_cosmo
+class PandocRenderer extends require "sitegen.renderers.html"
+ @escape_cosmo: escape_cosmo
+ @unescape_cosmo: unescape_cosmo
+ @parse_cosmo: parse_cosmo
+ source_ext: "md"
+ ext: "html"
cmd: "pandoc --mathjax --lua-filter pygments.lua >"
pandoc: (content) => Path.read_file write_exec @@cmd, content
diff --git a/site.moon b/site.moon
index 1d889a6..4bfdca5 100644
--- a/site.moon
+++ b/site.moon
@@ -29,7 +29,6 @@ get_files = (path, prefix=path) ->
files = for file in *files
file\gsub "^#{escape_patt prefix}/?", ""
- table.sort files
files
-- strip file extension from filename
diff --git a/spec/nginx_spec.moon b/spec/nginx_spec.moon
index dfd8403..a5ef9d8 100644
--- a/spec/nginx_spec.moon
+++ b/spec/nginx_spec.moon
@@ -104,6 +104,10 @@ describe "https://miti.sh/posts/", ->
describe "https://miti.sh/posts", ->
it "sends /posts/index.html", ->
+ with require "sitegen.path"
+ assert .exists("html/posts/index.html"),
+ "missing html/posts/index.html (try `make build file=blog.html`)"
+
request = req "https://miti.sh/posts"
assert.same request\statusCode!, 200
assert.same request\statusMessage!, "OK"
diff --git a/spec/renderers_spec.moon b/spec/renderers_spec.moon
index ca821fa..ee4e3ff 100644
--- a/spec/renderers_spec.moon
+++ b/spec/renderers_spec.moon
@@ -86,3 +86,11 @@ this code block has no label
assert.same [[]], out
+
+ it "escapes and unescapes double dollar signs", ->
+ out = flatten_html render [[
+```Makefile
+ $$name
+```]]
+
+ assert.same [[]], out