feat: initial commit

This commit is contained in:
Extroonie 2026-09-18 15:30:20 -07:00
commit 9b78e205eb
68 changed files with 3098 additions and 0 deletions

10
.dockerignore Normal file
View file

@ -0,0 +1,10 @@
.git
node_modules
dist
public
resources
.hugo_build.lock
.env
.env.*
!.env.example
deploy.sh

3
.env.example Normal file
View file

@ -0,0 +1,3 @@
SITE_PORT=8080
DEV_PORT=1313
SITE_BASE_URL=

1
.gitattributes vendored Normal file
View file

@ -0,0 +1 @@
* text=auto eol=lf

35
.github/workflows/check.yml vendored Normal file
View file

@ -0,0 +1,35 @@
name: Check site
on: [push, pull_request]
permissions:
contents: read
jobs:
build:
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- name: Check formatting, run tests, and build production image
run: docker build --target production -t extroonie .
- name: Check serving and 404 status
run: |
docker run -d --name extroonie-test -p 127.0.0.1:8080:8080 extroonie
status=$(curl --silent --show-error --fail \
--retry 10 --retry-all-errors --retry-delay 1 --retry-max-time 30 \
--connect-timeout 2 --max-time 5 \
--output /dev/null --write-out '%{http_code}' http://127.0.0.1:8080/)
echo "Homepage HTTP status: $status"
test "$status" = 200
status=$(curl --silent --show-error --connect-timeout 2 --max-time 5 \
--output /dev/null --write-out '%{http_code}' http://127.0.0.1:8080/not-a-page/)
echo "Missing page HTTP status: $status"
test "$status" = 404
- name: Show container diagnostics
if: failure()
run: |
docker inspect --format '{{json .State}}' extroonie-test || true
docker logs --tail 100 extroonie-test || true
- name: Remove test container
if: always()
run: docker rm -f extroonie-test || true

11
.gitignore vendored Normal file
View file

@ -0,0 +1,11 @@
.DS_Store
.idea/
node_modules/
dist/
public/
resources/_gen/
.hugo_build.lock
.env
.env.*
!.env.example
/deploy.sh

10
.prettierignore Normal file
View file

@ -0,0 +1,10 @@
layouts/**/*.json
layouts/**/*.xml
layouts/**/*.rss
dist/
public/
resources/
# The template formatter cannot parse dynamic heading tags such as <h{{ .Level }}>.
layouts/_default/_markup/render-heading.html

18
.prettierrc.json Normal file
View file

@ -0,0 +1,18 @@
{
"printWidth": 120,
"useTabs": true,
"tabWidth": 4,
"singleQuote": true,
"quoteProps": "as-needed",
"trailingComma": "all",
"endOfLine": "lf",
"plugins": ["prettier-plugin-go-template"],
"overrides": [
{
"files": ["layouts/**/*.html"],
"options": {
"parser": "go-template"
}
}
]
}

15
Caddyfile Normal file
View file

@ -0,0 +1,15 @@
:8080 {
root * /srv
encode zstd gzip
@immutable path_regexp fingerprint \.[a-f0-9]{64}\.
header @immutable Cache-Control "public, max-age=31536000, immutable"
header {
X-Content-Type-Options nosniff
Referrer-Policy strict-origin-when-cross-origin
}
file_server
handle_errors {
rewrite * /404.html
file_server
}
}

26
Dockerfile Normal file
View file

@ -0,0 +1,26 @@
# syntax=docker/dockerfile:1
FROM oven/bun:1.4.2-alpine AS development
ARG HUGO_VERSION=0.166.0
ARG TARGETARCH
RUN apk add --no-cache curl ca-certificates \
&& curl -fsSL "https://github.com/gohugoio/hugo/releases/download/v${HUGO_VERSION}/hugo_${HUGO_VERSION}_linux-${TARGETARCH}.tar.gz" -o /tmp/hugo.tar.gz \
&& curl -fsSL "https://github.com/gohugoio/hugo/releases/download/v${HUGO_VERSION}/hugo_${HUGO_VERSION}_checksums.txt" -o /tmp/checksums.txt \
&& awk -v name="hugo_${HUGO_VERSION}_linux-${TARGETARCH}.tar.gz" '$2 == name {print $1 " /tmp/hugo.tar.gz"}' /tmp/checksums.txt > /tmp/checksum.txt \
&& test -s /tmp/checksum.txt \
&& sha256sum -c /tmp/checksum.txt \
&& tar -xzf /tmp/hugo.tar.gz -C /usr/local/bin hugo \
&& rm /tmp/hugo.tar.gz /tmp/checksums.txt /tmp/checksum.txt
WORKDIR /site
COPY package.json bun.lock ./
RUN bun install --frozen-lockfile --ignore-scripts
COPY . .
EXPOSE 1313
CMD ["bun", "run", "dev", "--host", "0.0.0.0"]
FROM development AS build
ARG SITE_BASE_URL
RUN bun run format:check && bun run test && bun run build
FROM caddy:2.11.4-alpine AS production
LABEL com.extroonie.app="website"
COPY Caddyfile /etc/caddy/Caddyfile
COPY --from=build /site/dist /srv
EXPOSE 8080

121
LICENSE Normal file
View file

@ -0,0 +1,121 @@
Creative Commons Legal Code
CC0 1.0 Universal
CREATIVE COMMONS CORPORATION IS NOT A LAW FIRM AND DOES NOT PROVIDE
LEGAL SERVICES. DISTRIBUTION OF THIS DOCUMENT DOES NOT CREATE AN
ATTORNEY-CLIENT RELATIONSHIP. CREATIVE COMMONS PROVIDES THIS
INFORMATION ON AN "AS-IS" BASIS. CREATIVE COMMONS MAKES NO WARRANTIES
REGARDING THE USE OF THIS DOCUMENT OR THE INFORMATION OR WORKS
PROVIDED HEREUNDER, AND DISCLAIMS LIABILITY FOR DAMAGES RESULTING FROM
THE USE OF THIS DOCUMENT OR THE INFORMATION OR WORKS PROVIDED
HEREUNDER.
Statement of Purpose
The laws of most jurisdictions throughout the world automatically confer
exclusive Copyright and Related Rights (defined below) upon the creator
and subsequent owner(s) (each and all, an "owner") of an original work of
authorship and/or a database (each, a "Work").
Certain owners wish to permanently relinquish those rights to a Work for
the purpose of contributing to a commons of creative, cultural and
scientific works ("Commons") that the public can reliably and without fear
of later claims of infringement build upon, modify, incorporate in other
works, reuse and redistribute as freely as possible in any form whatsoever
and for any purposes, including without limitation commercial purposes.
These owners may contribute to the Commons to promote the ideal of a free
culture and the further production of creative, cultural and scientific
works, or to gain reputation or greater distribution for their Work in
part through the use and efforts of others.
For these and/or other purposes and motivations, and without any
expectation of additional consideration or compensation, the person
associating CC0 with a Work (the "Affirmer"), to the extent that he or she
is an owner of Copyright and Related Rights in the Work, voluntarily
elects to apply CC0 to the Work and publicly distribute the Work under its
terms, with knowledge of his or her Copyright and Related Rights in the
Work and the meaning and intended legal effect of CC0 on those rights.
1. Copyright and Related Rights. A Work made available under CC0 may be
protected by copyright and related or neighboring rights ("Copyright and
Related Rights"). Copyright and Related Rights include, but are not
limited to, the following:
i. the right to reproduce, adapt, distribute, perform, display,
communicate, and translate a Work;
ii. moral rights retained by the original author(s) and/or performer(s);
iii. publicity and privacy rights pertaining to a person's image or
likeness depicted in a Work;
iv. rights protecting against unfair competition in regards to a Work,
subject to the limitations in paragraph 4(a), below;
v. rights protecting the extraction, dissemination, use and reuse of data
in a Work;
vi. database rights (such as those arising under Directive 96/9/EC of the
European Parliament and of the Council of 11 March 1996 on the legal
protection of databases, and under any national implementation
thereof, including any amended or successor version of such
directive); and
vii. other similar, equivalent or corresponding rights throughout the
world based on applicable law or treaty, and any national
implementations thereof.
2. Waiver. To the greatest extent permitted by, but not in contravention
of, applicable law, Affirmer hereby overtly, fully, permanently,
irrevocably and unconditionally waives, abandons, and surrenders all of
Affirmer's Copyright and Related Rights and associated claims and causes
of action, whether now known or unknown (including existing as well as
future claims and causes of action), in the Work (i) in all territories
worldwide, (ii) for the maximum duration provided by applicable law or
treaty (including future time extensions), (iii) in any current or future
medium and for any number of copies, and (iv) for any purpose whatsoever,
including without limitation commercial, advertising or promotional
purposes (the "Waiver"). Affirmer makes the Waiver for the benefit of each
member of the public at large and to the detriment of Affirmer's heirs and
successors, fully intending that such Waiver shall not be subject to
revocation, rescission, cancellation, termination, or any other legal or
equitable action to disrupt the quiet enjoyment of the Work by the public
as contemplated by Affirmer's express Statement of Purpose.
3. Public License Fallback. Should any part of the Waiver for any reason
be judged legally invalid or ineffective under applicable law, then the
Waiver shall be preserved to the maximum extent permitted taking into
account Affirmer's express Statement of Purpose. In addition, to the
extent the Waiver is so judged Affirmer hereby grants to each affected
person a royalty-free, non transferable, non sublicensable, non exclusive,
irrevocable and unconditional license to exercise Affirmer's Copyright and
Related Rights in the Work (i) in all territories worldwide, (ii) for the
maximum duration provided by applicable law or treaty (including future
time extensions), (iii) in any current or future medium and for any number
of copies, and (iv) for any purpose whatsoever, including without
limitation commercial, advertising or promotional purposes (the
"License"). The License shall be deemed effective as of the date CC0 was
applied by Affirmer to the Work. Should any part of the License for any
reason be judged legally invalid or ineffective under applicable law, such
partial invalidity or ineffectiveness shall not invalidate the remainder
of the License, and in such case Affirmer hereby affirms that he or she
will not (i) exercise any of his or her remaining Copyright and Related
Rights in the Work or (ii) assert any associated claims and causes of
action with respect to the Work, in either case contrary to Affirmer's
express Statement of Purpose.
4. Limitations and Disclaimers.
a. No trademark or patent rights held by Affirmer are waived, abandoned,
surrendered, licensed or otherwise affected by this document.
b. Affirmer offers the Work as-is and makes no representations or
warranties of any kind concerning the Work, express, implied,
statutory or otherwise, including without limitation warranties of
title, merchantability, fitness for a particular purpose, non
infringement, or the absence of latent or other defects, accuracy, or
the present or absence of errors, whether or not discoverable, all to
the greatest extent permissible under applicable law.
c. Affirmer disclaims responsibility for clearing rights of other persons
that may apply to the Work or any use thereof, including without
limitation any person's Copyright and Related Rights in the Work.
Further, Affirmer disclaims responsibility for obtaining any necessary
consents, permissions or other rights required for any use of the
Work.
d. Affirmer understands and acknowledges that Creative Commons is not a
party to this document and has no duty or obligation with respect to
this CC0 or use of the Work.

12
LICENSING.md Normal file
View file

@ -0,0 +1,12 @@
# Licensing
Ishmaam Khan dedicates his original code, writing, documentation, and artwork in
this project to the public domain under
[CC0 1.0 Universal](https://creativecommons.org/publicdomain/zero/1.0/).
This dedication applies to the rights he holds in that material.
The complete legal text is in [LICENSE](LICENSE), with a copy distributed at
[static/licenses/cc0.txt](static/licenses/cc0.txt).
Third-party quotations, images, fonts, and libraries are excluded from this
dedication and retain their own terms. See [THIRD_PARTY_NOTICES.md](THIRD_PARTY_NOTICES.md).

3
README.md Normal file
View file

@ -0,0 +1,3 @@
# extroonie.com
My personal website, built with [Hugo](https://gohugo.io/).

30
THIRD_PARTY_NOTICES.md Normal file
View file

@ -0,0 +1,30 @@
# Third-party notices
- Newsreader, The Newsreader Project Authors:
[SIL Open Font License 1.1](static/licenses/newsreader.txt).
Distributed by @fontsource-variable/newsreader 5.3.0.
- KaTeX 0.18.7 stylesheet and fonts: [MIT license](static/licenses/katex.txt),
Khan Academy and contributors.
Hugo provides its embedded KaTeX renderer.
The complete upstream license texts are in [static/licenses/](static/licenses/)
and distributed with the site. Build dependencies retain the license files included in their packages;
installed dependencies are not included in the source ZIP.
The [Pale Blue Dot](static/images/Pale_Blue_Dot.png) link-preview photograph was
taken by NASA’s Voyager 1 spacecraft.
Image credit: NASA/JPL.
See the [JPL image use policy](https://www.jpl.nasa.gov/jpl-image-use-policy/).
“[Why I don’t like Node.js workspaces](content/writing/nodejs-workspaces.md)”
contains third-party material:
- The opening blockquote is from the npm CLI v7
[workspaces documentation](https://docs.npmjs.com/cli/v7/using-npm/workspaces/),
copyright npm, Inc. and contributors. The upstream npm CLI license is
Artistic-2.0; its complete notice is included in
[static/licenses/npm.txt](static/licenses/npm.txt).
The quotation is typeset with smart punctuation; the wording is retained.
Third-party material retains its own terms and is excluded from the project's
CC0 dedication, as described in [LICENSING.md](LICENSING.md).

8
archetypes/default.md Normal file
View file

@ -0,0 +1,8 @@
---
title: '{{ replace .File.ContentBaseName "-" " " | title }}'
date: '{{ .Date }}'
draft: true
format: post
description: ''
subtitle: ''
---

637
assets/css/style.css Normal file
View file

@ -0,0 +1,637 @@
@font-face {
font-family: Newsreader;
src: url('/fonts/newsreader-latin-wght-normal.woff2') format('woff2');
font-style: normal;
font-weight: 200 800;
font-display: swap;
}
@font-face {
font-family: Newsreader;
src: url('/fonts/newsreader-latin-wght-italic.woff2') format('woff2');
font-style: italic;
font-weight: 200 800;
font-display: swap;
}
:root {
color-scheme: light;
--paper: #f5f3eb;
--ink: #292823;
--muted: #6d6a60;
--accent: #eee3a5;
--surface: #ece9df;
--glow: #f3e7ac16;
}
:root[data-theme='dark'] {
color-scheme: dark;
--paper: #1b1b19;
--ink: #e5e2d9;
--muted: #aaa79b;
--accent: #d5c887;
--surface: #272722;
--glow: #d5c88706;
}
@media (prefers-color-scheme: dark) {
:root:not([data-theme]) {
color-scheme: dark;
--paper: #1b1b19;
--ink: #e5e2d9;
--muted: #aaa79b;
--accent: #d5c887;
--surface: #272722;
--glow: #d5c88706;
}
}
* {
box-sizing: border-box;
}
html {
scroll-behavior: smooth;
scroll-padding-top: 2rem;
}
body {
margin: 0;
min-height: 100vh;
background: radial-gradient(ellipse at 80% 0%, var(--glow), transparent 60%), var(--paper);
background-repeat: no-repeat;
color: var(--ink);
font:
400 1.25rem/1.6 Newsreader,
Georgia,
serif;
-webkit-font-smoothing: antialiased;
}
[hidden] {
display: none !important;
}
::selection {
background: var(--accent);
color: #25231c;
}
a {
color: inherit;
text-decoration-thickness: 1px;
text-underline-offset: 0.15em;
}
a:hover {
text-decoration-color: var(--muted);
}
button,
summary,
a {
touch-action: manipulation;
}
button {
font: inherit;
color: inherit;
cursor: pointer;
}
:focus-visible {
outline: 2px solid var(--muted);
outline-offset: 5px;
}
[tabindex='-1']:focus {
outline: none;
}
.shell {
width: min(100% - 3rem, 38rem);
margin-inline: auto;
}
.site-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1.5rem;
padding-block: 2.25rem 2rem;
}
.site-name {
font-size: 1.8rem;
font-weight: 600;
line-height: 1.2;
margin: 0;
text-decoration: none;
}
.site-name a {
text-decoration: inherit;
}
.theme-toggle {
flex: 0 0 auto;
width: 42px;
height: 44px;
padding: 0;
background: none;
border: 0;
position: relative;
}
.theme-toggle::before {
content: '';
position: absolute;
inset: 12px 0;
border: 1px solid var(--muted);
border-radius: 20px;
}
.toggle-thumb {
width: 16px;
height: 16px;
position: absolute;
left: 3px;
top: 14px;
display: grid;
place-items: center;
border-radius: 50%;
background: var(--ink);
color: var(--paper);
transition:
transform 0.2s ease,
background 0.2s ease;
}
.toggle-thumb svg {
width: 12px;
height: 12px;
}
[data-theme='dark'] .toggle-thumb {
transform: translateX(20px);
background: var(--accent);
color: #292823;
}
h1,
h2,
h3,
h4 {
font-weight: 600;
line-height: 1.25;
text-wrap: balance;
}
h1 {
font-size: 2.1rem;
margin: 0 0 0.6rem;
}
h2 {
font-size: 1.5rem;
margin: 2.25rem 0 1rem;
}
h3 {
font-size: 1.3rem;
margin: 2rem 0 0.8rem;
}
p {
margin: 0 0 1.25rem;
}
.intro > p {
margin-bottom: 0.8rem;
}
.contact-links {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.1rem 0.8rem;
font-size: 1rem;
margin-block: 0.2rem 1rem;
}
.contact-links > span:first-child {
color: var(--muted);
}
.contact-link {
display: inline-flex;
align-items: center;
gap: 0.25rem;
border: 0;
padding: 0.25rem 0;
background: none;
text-decoration: underline;
min-height: 36px;
}
.discord-fallback {
font-size: 1rem;
}
summary {
cursor: pointer;
width: fit-content;
text-decoration: underline;
text-underline-offset: 0.15em;
list-style: none;
}
summary::-webkit-details-marker {
display: none;
}
.less-label,
.biography[open] .more-label {
display: none;
}
.biography[open] .less-label {
display: inline;
}
.biography-content {
padding-top: 1.75rem;
}
.biography[open] .biography-content {
animation: reveal 0.2s ease;
}
@keyframes reveal {
from {
opacity: 0;
transform: translateY(-4px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.contents,
.article-contents {
font-size: 1rem;
color: var(--muted);
margin-bottom: 2rem;
}
.contents nav > ul {
display: flex;
flex-wrap: wrap;
gap: 0.4rem 1rem;
list-style: none;
padding: 0;
margin: 0.4rem 0 0;
}
.article-contents {
margin-top: 1.5rem;
}
.article-contents ul {
padding-left: 1.2rem;
}
.heading-link {
text-decoration: none;
}
.heading-anchor {
display: inline-block;
font:
400 0.65em system-ui,
sans-serif;
color: var(--muted);
opacity: 0;
margin-left: 0.5em;
transition: opacity 0.15s ease;
}
:is(h2, h3, h4):is(:hover, :focus-within, :target) .heading-anchor {
opacity: 1;
}
.writing-section {
margin-top: 2rem;
}
.section-heading {
display: flex;
align-items: baseline;
gap: 0.65rem;
}
.section-heading h2 {
margin: 0;
}
.rss-link {
color: var(--muted);
font:
400 0.75rem/1.5 system-ui,
sans-serif;
}
.writing-list {
margin: 0.9rem 0 0;
padding-left: 1.25rem;
}
.writing-list li {
padding-left: 0.1rem;
margin-bottom: 0.6rem;
}
.writing-list li::marker {
font-size: 0.7em;
}
.article-intro {
margin-top: 0.75rem;
}
.article-intro h1 {
font-size: clamp(2.25rem, 4.6875vw, 3rem);
line-height: 1.15;
overflow-wrap: break-word;
}
.subtitle {
font-size: 1.3rem;
font-style: italic;
color: var(--muted);
margin-bottom: 1rem;
}
.publication {
position: relative;
display: flex;
gap: 0.25rem 1rem;
flex-wrap: wrap;
color: var(--muted);
font-size: 0.95rem;
}
.article-prose {
margin-top: 2rem;
}
.article-prose h2 {
font-size: 1.75rem;
}
.prose {
overflow-wrap: break-word;
}
.prose img {
max-width: 100%;
height: auto;
display: block;
margin-block: 1rem;
}
.prose ul,
.prose ol {
padding-left: 1.4rem;
}
.prose li {
margin-block: 0.4rem;
}
.footnotes {
margin-top: 3rem;
font-size: 0.95rem;
}
.footnotes li,
sup[id^='fnref:'] {
scroll-margin-top: 2rem;
}
.footnotes li:target {
background: var(--surface);
}
.footnote-ref {
padding: 0.12em 0.3em;
border-radius: 3px;
background: var(--surface);
text-decoration: none;
}
.footnote-preview {
display: block;
position: fixed;
z-index: 10;
width: min(24rem, calc(100vw - 2rem));
max-height: min(20rem, calc(100dvh - 2rem));
overflow: auto;
padding: 1rem 1.15rem;
background: var(--paper);
color: var(--ink);
border-radius: 5px;
box-shadow: 0 4px 24px rgb(0 0 0 / 15%);
font-size: 0.95rem;
line-height: 1.5;
}
.footnote-preview > :last-child {
margin-bottom: 0;
}
blockquote {
margin: 1.5rem 0;
padding: 0.2rem 0 0.2rem 1.5rem;
border-left: 1px solid color-mix(in srgb, var(--accent) 55%, var(--muted) 45%);
font-style: italic;
}
blockquote code,
blockquote pre,
blockquote .katex {
font-style: normal;
}
blockquote > :last-child {
margin-bottom: 0;
}
blockquote p {
margin-bottom: 1rem;
}
code,
pre {
font-family: ui-monospace, SFMono-Regular, Consolas, monospace;
font-size: 0.78em;
}
code {
background: var(--surface);
padding: 0.12em 0.25em;
border-radius: 3px;
}
pre {
background: var(--surface);
padding: 1rem;
overflow: auto;
line-height: 1.65;
}
pre code {
background: none;
padding: 0;
font-size: inherit;
}
table {
display: block;
max-width: 100%;
overflow-x: auto;
border-collapse: collapse;
font-size: 1rem;
}
th,
td {
padding: 0.5rem 0.75rem;
text-align: left;
}
th {
background: var(--surface);
}
.math-display {
overflow-x: auto;
overflow-y: hidden;
padding-block: 0.8rem;
}
.numbered-equation {
display: flex;
align-items: center;
gap: 1rem;
}
.numbered-equation .math-display {
flex: 1;
min-width: 0;
}
.equation-number {
flex-shrink: 0;
font-size: 1rem;
}
.katex {
font-size: 1.08em;
}
.abstract {
font-size: 1.1rem;
}
main {
padding-bottom: 4rem;
}
.notice {
position: fixed;
bottom: 1.5rem;
left: 50%;
transform: translate(-50%, 0.5rem);
opacity: 0;
pointer-events: none;
padding: 0.5rem 1rem;
background: var(--ink);
color: var(--paper);
border-radius: 4px;
font:
0.9rem/1.5 system-ui,
sans-serif;
transition:
opacity 0.15s,
transform 0.15s;
}
.notice.visible {
opacity: 1;
transform: translate(-50%, 0);
}
.skip-link {
position: absolute;
left: 1rem;
top: -5rem;
background: var(--paper);
}
.skip-link:focus {
top: 1rem;
}
hr {
display: none;
}
@media (max-width: 600px) {
body {
font-size: 1.1875rem;
}
.site-header {
padding-top: 1.5rem;
}
.site-name {
font-size: 1.65rem;
}
.article-prose h2 {
font-size: 1.625rem;
}
h1 {
font-size: 1.9rem;
}
blockquote {
padding-left: 1rem;
}
}
@media (prefers-reduced-motion: reduce) {
html {
scroll-behavior: auto;
}
*,
*::before,
*::after {
animation: none !important;
transition: none !important;
}
}
@media print {
body {
background: white;
color: black;
}
.theme-toggle,
.contact-links,
.contents,
.article-contents,
.contents-reopen,
.skip-link,
.heading-anchor,
.biography > summary {
display: none;
}
.shell {
width: 100%;
}
.biography::details-content {
content-visibility: visible;
}
a {
color: black;
}
}
.article-contents a[aria-current] {
color: var(--ink);
text-decoration-color: currentColor;
}
.article-contents a {
display: inline-block;
padding-block: 0.25rem;
}
.footnote-preview a {
overflow-wrap: anywhere;
}
.contents-reopen,
.contents-close {
border: 0;
background: none;
color: var(--muted);
}
.contents-reopen {
position: absolute;
left: 0;
bottom: 0;
font-size: 1rem;
padding: 0;
text-decoration: underline;
}
.contents-heading {
display: flex;
align-items: center;
justify-content: space-between;
color: var(--ink);
}
.contents-close {
font-size: 1.5rem;
width: 44px;
height: 44px;
line-height: 1;
}
@media screen and (min-width: 64rem) {
.has-contents {
--reading-start: max(16rem, calc((100% - 38rem) / 2));
--sidebar-width: clamp(13rem, calc(var(--reading-start) - 3rem), 15rem);
}
.has-contents .shell {
margin-left: var(--reading-start);
margin-right: 0;
}
.has-contents .publication {
/* Keep the reopening link's row reserved even while the sidebar is open. */
padding-bottom: 2rem;
}
.article-contents.sidebar {
position: fixed;
top: 7rem;
left: calc(var(--reading-start) - var(--sidebar-width) - 1.5rem);
width: var(--sidebar-width);
max-height: calc(100dvh - 8.5rem);
overflow: auto;
margin: 0;
font-size: 1.125rem;
line-height: 1.5;
overscroll-behavior: contain;
}
.article-contents.sidebar .contents-heading {
font-size: 1.375rem;
font-weight: 600;
}
.article-contents.sidebar > summary {
display: none;
}
.article-contents.sidebar ul {
list-style: none;
padding-left: 0;
}
.article-contents.sidebar ul ul {
padding-left: 1rem;
}
.article-contents.sidebar a {
text-decoration: none;
overflow-wrap: anywhere;
}
.article-contents.sidebar a:hover,
.article-contents.sidebar a[aria-current] {
text-decoration: underline;
}
}

84
assets/js/contents.js Normal file
View file

@ -0,0 +1,84 @@
(() => {
const contents = document.querySelector('.article-contents');
if (!contents) return;
const reopen = document.querySelector('.contents-reopen');
const closeButton = contents.querySelector('.contents-close');
const title = contents.querySelector('.contents-heading');
const wide = matchMedia('(min-width: 64rem)');
let desktopOpen = true;
let mobileOpen = false;
const links = [...contents.querySelectorAll('nav a')];
const headings = links.map((link) => document.getElementById(decodeURIComponent(link.hash.slice(1))));
function render() {
contents.classList.toggle('sidebar', wide.matches);
contents.open = wide.matches ? desktopOpen : mobileOpen;
title.hidden = !wide.matches;
reopen.hidden = !wide.matches || desktopOpen;
reopen.setAttribute('aria-expanded', String(desktopOpen));
}
function close() {
desktopOpen = false;
render();
reopen.focus({ preventScroll: true });
}
closeButton.addEventListener('click', close);
contents.addEventListener('keydown', (event) => {
if (event.key === 'Escape' && wide.matches && desktopOpen) close();
});
reopen.addEventListener('click', () => {
desktopOpen = true;
render();
closeButton.focus({ preventScroll: true });
});
contents.querySelector('summary').addEventListener('click', (event) => {
if (wide.matches) return;
event.preventDefault();
mobileOpen = !contents.open;
render();
});
contents.addEventListener('click', (event) => {
if (
event.defaultPrevented ||
event.button !== 0 ||
event.metaKey ||
event.ctrlKey ||
event.shiftKey ||
event.altKey
)
return;
const link = event.target.closest('nav a');
if (!link) return;
if (!wide.matches) {
mobileOpen = false;
render();
}
const heading = headings[links.indexOf(link)];
heading?.focus({ preventScroll: true });
});
wide.addEventListener('change', render);
let scheduled = false;
function update() {
scheduled = false;
let current = -1;
headings.forEach((heading, index) => {
if (heading && heading.getBoundingClientRect().top <= 120) current = index;
});
links.forEach((link, index) => {
if (index === current) link.setAttribute('aria-current', 'location');
else link.removeAttribute('aria-current');
});
}
window.addEventListener(
'scroll',
() => {
if (!scheduled) {
scheduled = true;
requestAnimationFrame(update);
}
},
{ passive: true },
);
window.addEventListener('resize', update);
render();
update();
})();

81
assets/js/footnotes.js Normal file
View file

@ -0,0 +1,81 @@
(() => {
const references = document.querySelectorAll('.footnote-ref');
if (!references.length) return;
const preview = document.createElement('span');
preview.id = 'footnote-preview';
preview.className = 'footnote-preview';
preview.setAttribute('role', 'note');
preview.hidden = true;
document.body.append(preview);
let active;
let timer;
function close() {
clearTimeout(timer);
if (active && preview.contains(document.activeElement)) active.focus({ preventScroll: true });
active?.removeAttribute('aria-controls');
active = null;
preview.hidden = true;
}
function scheduleClose() {
clearTimeout(timer);
timer = setTimeout(() => {
if (!preview.contains(document.activeElement) && document.activeElement !== active) close();
}, 180);
}
function show(reference) {
if (active === reference) {
clearTimeout(timer);
return;
}
close();
const note = document.getElementById(decodeURIComponent(reference.hash.slice(1)));
if (!note) return;
const content = note.cloneNode(true);
content.querySelectorAll('.footnote-backref').forEach((link) => link.remove());
content.querySelectorAll('[id]').forEach((element) => element.removeAttribute('id'));
preview.replaceChildren(...content.childNodes);
active = reference;
reference.setAttribute('aria-controls', preview.id);
preview.setAttribute('aria-label', `Footnote ${reference.textContent}`);
reference.closest('sup').after(preview);
preview.hidden = false;
const rect = reference.getBoundingClientRect();
const gap = 8;
const inset = 16;
const left = Math.max(inset, Math.min(rect.left, innerWidth - preview.offsetWidth - inset));
const below = rect.bottom + gap;
const top = below + preview.offsetHeight <= innerHeight - inset ? below : rect.top - preview.offsetHeight - gap;
preview.style.left = `${left}px`;
preview.style.top = `${Math.max(inset, top)}px`;
}
for (const reference of references) {
reference.closest('sup').tabIndex = -1;
const note = document.getElementById(decodeURIComponent(reference.hash.slice(1)));
if (note) note.tabIndex = -1;
reference.addEventListener('pointerenter', (event) => {
if (event.pointerType === 'mouse') show(reference);
});
reference.addEventListener('pointerleave', scheduleClose);
reference.addEventListener('focus', () => {
if (reference.matches(':focus-visible')) show(reference);
});
reference.addEventListener('blur', scheduleClose);
reference.addEventListener('click', close);
}
preview.addEventListener('pointerenter', () => clearTimeout(timer));
preview.addEventListener('pointerleave', scheduleClose);
preview.addEventListener('focusin', () => clearTimeout(timer));
preview.addEventListener('focusout', scheduleClose);
document.addEventListener('keydown', (event) => {
if (event.key === 'Escape') close();
});
window.addEventListener('resize', close);
window.addEventListener(
'scroll',
() => {
if (!preview.contains(document.activeElement)) close();
},
{ passive: true },
);
})();

88
assets/js/main.js Normal file
View file

@ -0,0 +1,88 @@
(() => {
const root = document.documentElement;
const toggle = document.querySelector('.theme-toggle');
const system = matchMedia('(prefers-color-scheme: dark)');
let manual = ['light', 'dark'].includes(root.dataset.themeChoice);
function setTheme(theme) {
root.dataset.theme = theme;
toggle?.setAttribute('aria-checked', String(theme === 'dark'));
}
if (toggle) {
toggle.hidden = false;
setTheme(root.dataset.theme || (system.matches ? 'dark' : 'light'));
toggle.addEventListener('click', () => {
const theme = root.dataset.theme === 'dark' ? 'light' : 'dark';
manual = true;
root.dataset.themeChoice = theme;
setTheme(theme);
try {
localStorage.setItem('theme', theme);
} catch {}
});
system.addEventListener('change', () => {
if (!manual) setTheme(system.matches ? 'dark' : 'light');
});
window.addEventListener('storage', (event) => {
if (event.key !== 'theme') return;
manual = ['light', 'dark'].includes(event.newValue);
root.dataset.themeChoice = manual ? event.newValue : 'system';
setTheme(manual ? event.newValue : system.matches ? 'dark' : 'light');
});
}
const biography = document.querySelector('#about');
function fragmentTarget(hash = location.hash) {
let id;
try {
id = decodeURIComponent(hash.slice(1));
} catch {
return;
}
return id ? document.getElementById(id) : null;
}
function revealFragment() {
const target = fragmentTarget();
if (!target) return;
if (biography?.contains(target)) biography.open = true;
requestAnimationFrame(() => {
target.scrollIntoView({ block: 'start' });
target.focus({ preventScroll: true });
});
}
window.addEventListener('hashchange', revealFragment);
if (location.hash) revealFragment();
// Keep native fragment navigation: browser history, URL, and no-JS behavior.
document.addEventListener('click', (event) => {
if (
event.defaultPrevented ||
event.button !== 0 ||
event.metaKey ||
event.ctrlKey ||
event.shiftKey ||
event.altKey
)
return;
const link = event.target.closest('a[href^="#"]');
if (!link) return;
const target = fragmentTarget(link.hash);
if (target && biography?.contains(target)) biography.open = true;
});
const notice = document.querySelector('.notice');
let timer;
document.querySelectorAll('[data-copy]').forEach((button) => {
const fallback = button.parentElement.querySelector('.discord-fallback');
button.hidden = false;
fallback.hidden = true;
button.addEventListener('click', async () => {
try {
await navigator.clipboard.writeText(button.dataset.copy);
notice.textContent = 'Discord username copied.';
} catch {
fallback.hidden = false;
notice.textContent = 'Select the username to copy it.';
}
clearTimeout(timer);
notice.classList.add('visible');
timer = setTimeout(() => notice.classList.remove('visible'), 3000);
});
});
})();

10
assets/js/theme-init.js Normal file
View file

@ -0,0 +1,10 @@
(() => {
let choice = 'system';
try {
choice = localStorage.getItem('theme') || 'system';
} catch {}
if (!['light', 'dark', 'system'].includes(choice)) choice = 'system';
const dark = choice === 'dark' || (choice === 'system' && matchMedia('(prefers-color-scheme: dark)').matches);
document.documentElement.dataset.theme = dark ? 'dark' : 'light';
document.documentElement.dataset.themeChoice = choice;
})();

31
bun.lock Normal file
View file

@ -0,0 +1,31 @@
{
"lockfileVersion": 1,
"configVersion": 0,
"workspaces": {
"": {
"name": "extroonie",
"devDependencies": {
"@fontsource-variable/newsreader": "5.3.0",
"katex": "0.18.7",
"prettier": "3.9.8",
"prettier-plugin-go-template": "0.0.15",
},
},
},
"overrides": {
"ulid": "3.0.2",
},
"packages": {
"@fontsource-variable/newsreader": ["@fontsource-variable/newsreader@5.3.0", "", {}, "sha512-rrzYi43qMpbzwuFtf9OkWH8sxAPVPcQQQEwXpPtwaKYeJ8yVg5aLs5kawmo1f2Q1t1M38TLmEKCkGVDsYwgdFw=="],
"commander": ["commander@15.0.0", "", {}, "sha512-z67u4ZhzCL/Tydu1lJARtEZYWbWaN7oYLHbsuzocr6y4N6WZAagG3RQ4FW61V1/0+jImpj293XfrcYnd1qxtPg=="],
"katex": ["katex@0.18.7", "", { "dependencies": { "commander": "^15.0.0" }, "bin": { "katex": "cli.js" } }, "sha512-h+UCwkZ+4Jz8WQ7MLGfj7UVFrRCizGb912fwF4luGdYsC5paYG1vx+jy+KRcC/XkpjGva/P7nAWuxNnPzRvzHw=="],
"prettier": ["prettier@3.9.8", "", { "bin": { "prettier": "bin/prettier.cjs" } }, "sha512-WRFq3Wn3WId7LLROfMLdH7xaFr2jR62wU8nLO6rQUOLOxNZUviyJQs1M0iIhLexSFy+L+w0ch66wtoO2jRjG0A=="],
"prettier-plugin-go-template": ["prettier-plugin-go-template@0.0.15", "", { "dependencies": { "ulid": "^2.3.0" }, "peerDependencies": { "prettier": "^3.0.0" } }, "sha512-WqU92E1NokWYNZ9mLE6ijoRg6LtIGdLMePt2C7UBDjXeDH9okcRI3zRqtnWR4s5AloiqyvZ66jNBAa9tmRY5EQ=="],
"ulid": ["ulid@3.0.2", "", { "bin": { "ulid": "dist/cli.js" } }, "sha512-yu26mwteFYzBAot7KVMqFGCVpsF6g8wXfJzQUHvu1no3+rRRSFcSV2nKeYvNPLD2J4b08jYBDhHUjeH0ygIl9w=="],
}
}

35
compose.yaml Normal file
View file

@ -0,0 +1,35 @@
services:
site:
image: ${SITE_IMAGE:-extroonie:local}
build:
context: .
target: production
args:
SITE_BASE_URL: ${SITE_BASE_URL:-}
ports:
- '127.0.0.1:${SITE_PORT:-8080}:8080'
restart: unless-stopped
healthcheck:
test: [CMD, wget, -q, -O, /dev/null, 'http://127.0.0.1:8080/']
interval: 5s
timeout: 3s
retries: 5
start_period: 5s
dev:
profiles: [dev]
build:
context: .
target: development
ports:
- '127.0.0.1:${DEV_PORT:-1313}:${DEV_PORT:-1313}'
volumes:
- .:/site
- dev_dependencies:/site/node_modules
command:
[
sh,
-c,
'bun install --frozen-lockfile --ignore-scripts && exec bun run dev --host 0.0.0.0 --port ${DEV_PORT:-1313}',
]
volumes:
dev_dependencies:

42
content/_index.md Normal file
View file

@ -0,0 +1,42 @@
---
title: Ishmaam Khan
introduction: "Hi! I’m Ishmaam. I’m curious about all sorts of things. This is where I share some of what I’ve been learning, building, and thinking about."
---
## Computers! {#computers}
I was 13 when I learned that the apps I used were made with code. The idea that I could learn to write it and build something myself was so exciting that I had to try.
A few months and a lot of trial and error later, I’d made something simple that actually *ran*. If you’re a programmer, you know the feeling. If you’re not, imagine spending ages making something and finally seeing it work exactly as you intended. I couldn’t wait to make something else.
Eight years later, I’m still at it! I’m a backend software developer, and I mostly use JavaScript and TypeScript. I also know Java, Rust, and Go. I’ve built quite a few things over the years, most of which I’ve kept to myself. Maybe I’ll share some of them here one of these days.
As I learned more about programming, I became interested in computer science more broadly. I’ve since taught myself a fair bit, with a huge amount of help from freely available courses and lectures from MIT, Harvard, Stanford, and others. Lately, I’ve been exploring AI, particularly machine learning. I haven’t studied any of this formally yet, but I plan to. I’ll keep you posted!
## Science! {#science}
Like a lot of kids, I was fascinated by space. I never grew out of it.
When I was 11 or 12, I came across Stephen Hawking’s *A Brief History of Time* in my father’s rather enormous book collection. The subtitle caught my eye: *From the Big Bang to Black Holes*. Black holes!!! Of course I had to read it.
Very little of it made sense to me at that age. Mostly, I picked up fascinating little facts that I’d share, unprompted, with anyone who happened to be around. They weren’t usually as excited as I was. Still, I kept returning to the book over the years. Each time, I understood a little more and began to appreciate ideas that had gone completely over my head before. I’m so grateful to that book for sparking my curiosity and getting me to ask questions I might never have considered otherwise.
Physics has been a particular love of mine ever since. Psychology gets me excited too — I could happily spend hours learning about how our minds work! And I couldn’t leave mathematics out of this. I love it too!
## Philosophy! {#philosophy}
Reading about physics, especially in *A Brief History of Time*, left me with questions that led me to philosophy. I started reading philosophy to explore them, and it’s been a huge part of my life ever since.
What interests me? Pretty much everything! I’m not even joking. I’ve yet to find a branch of philosophy that doesn’t interest me. I especially love how the different areas connect with one another and with other fields. You might start with a narrow question and discover that answering it means rethinking something you’d always taken for granted.
If you asked which subjects I’d feel most comfortable discussing in depth, I’d probably say free will, metaethics and ethics, and philosophy of religion. Those are the areas I know best, but I’m always happy to talk about others or learn about something I haven’t explored yet.
## Other things! {#other-things}
Beyond all that, I have a particular fondness for language, especially English — at least for now. I love reading something so well phrased that I stop to admire the wording. I’ve spent a lot of time trying to bring that same care to my own writing and conversation. I admire people who can make a complicated thought easy to follow without losing any of its subtlety. That’s a skill I wish we valued more, and one I want to keep developing.
I watch a lot of movies and TV, too. According to my media tracker, I watch more drama than anything else — apparently! I would’ve guessed science fiction, which comes next, followed by action and crime. I much prefer live action to animation, though a few animated films and shows recommended by friends are on my list. For someone who watches so much fiction, I’ve read almost none. My reading has been overwhelmingly nonfiction, but I’d like to change that.
As for music, I mostly listen to pop and rock, with some rap here and there. I don’t listen all that much, and I tend to come back to the same favourites. I’d like to branch out, though, so if there’s something you think I should hear, send it my way!
I also care a great deal about digital privacy. We spend so much of our lives online, and I think we deserve control over what we share, who has access to it, and how it’s used. Exercising that control shouldn’t require a technical background. I try to help where I can, whether that means explaining why privacy matters or helping someone make a few practical changes to protect their own.

View file

@ -0,0 +1,4 @@
---
title: Writing
description: "On computers, science, philosophy, and whatever else gets me thinking."
---

View file

@ -0,0 +1,5 @@
---
title: Essays
description: "Longer arguments and things worth taking a closer look at."
filterKind: essay
---

View file

@ -0,0 +1,45 @@
---
title: "Why I don't like Node.js workspaces"
date: 2024-01-02
format: post
description: "Monorepos are cool, but the way they're implemented using workspaces... isn't."
---
I dislike the workspaces feature of package managers in Node.js, and I will explain why in this post.
## What are workspaces?
If you don't know what workspaces are, here's a brief description from [npm Docs](https://docs.npmjs.com/cli/v7/using-npm/workspaces):
> Workspaces is a generic term that refers to the set of features in the npm cli that provides support to managing multiple packages from your local files system from within a singular top-level, root package.
>
> This set of features makes up for a much more streamlined workflow handling linked packages from the local file system. Automating the linking process as part of `npm install` and avoiding manually having to use `npm link` in order to add references to packages that should be symlinked into the current `node_modules` folder.
>
> We also refer to these packages being auto-symlinked during `npm install` as a single workspace, meaning it's a nested package within the current local file system that is explicitly defined in the [`package.json`](https://docs.npmjs.com/cli/v7/configuring-npm/package-json#workspaces) `workspaces` configuration.
It exists in most of the package managers, including [pnpm](https://pnpm.io/workspaces) and [Yarn](https://yarnpkg.com/features/workspaces). They can also be integrated with build tools such as [Turborepo](https://turbo.build/repo) and [Nx](https://nx.dev/).
## The problem
In a monorepo setup, workspaces allow you to install dependencies separately for each package or application in the repo. This is different from a traditional setup where all dependencies would be installed at the root of the repo.
A key distinction exists between regular dependencies and developer dependencies like linting/testing tools. Regular dependencies can be installed at the package root as usual with workspaces. However, developer dependencies typically need to be hoisted and installed only at the workspace root.
This can be problematic because it means each workspace must install its own copies of all the various dev tools needed, leading to duplication. There is no central place to consolidate tooling.
## The solution
> I've grown increasingly convinced over the past year that the TypeScript monorepos should move towards installing everything at the root of the monorepo. This is also what Nx recommends, at least for brand-new monorepos:
>
> ![A recommendation to install dependencies at the root of a monorepo](/images/nodejs-workspaces-img.png)
>
> This strategy has a lot of elegance to it, as it makes working in the repo ultra-simple and rips out a ton of the crappy complexity that we had before:
>
> 1. You only have one `node_modules` directory, and it's at the root of the monorepo. The mental model here is super clean.
> 2. You don't use the "workspaces" feature of the package manager whatsoever. It's very annoying to have to remember to put in workspace-related command-line flags whenever e.g. adding a new dep. This brings parity between "normal" single-project repos and monorepos.
> 3. You don't have to worry about making somewhat-arbitrary distinctions between "ESLint-like" deps that should apply to the entire mono-repo, and "library-like" deps that should only apply to specific packages. Everything just goes into the root as a normal dep or a dev dep (in the same way that a normal/single repo does it).
> 4. Updating dependencies becomes ultra simple - you don't need some over-engineered monorepo tool to do it! You can just run conventional tools like [`npm-check-updates`](https://github.com/raineorshine/npm-check-updates), and then after updating a single "package.json" file, you can simply run `npm/yarn/pnpm install`.
> - And checking for breaking changes after a new dep update also becomes easy. After updating, we can trigger a commit, and TypeScript will take over and let you know in CI in any of the new API changes broke one of your existing packages. Then we can revert the commit if needed, or continue to go on and make another commit to fix to the individual broken packages (or coordinate with other teams, if necessary).
> - In some monorepos, some packages are published to npm, so they need their own individual "package.json" files. Nx offers the option to automatically create package.json files when publishing, but sometimes you will want more control over them, and will choose to manually manage them yourself. (Does Turborepo have an analogous feature?) If you do manually manage "package.json" files, then you need an additional tool to propagate version changes downward from the root "package.json". With that said, even in this situation, I think it is preferable to use simple tools like [`syncpack`](https://github.com/JamieMason/syncpack) to do this, rather than complicated tools like e.g. `lerna`. (`syncpack` is essentially just a glorified copy-paster, so even in this situation, we are still maintaining the simple mental model of how dependencies work.)
[Source](https://github.com/vercel/turbo/issues/2060#issuecomment-1416135197)

View file

@ -0,0 +1,5 @@
---
title: Posts
description: "Observations, explanations, and occasional notes."
filterKind: post
---

15
data/contacts.toml Normal file
View file

@ -0,0 +1,15 @@
[[links]]
name = 'Email'
url = 'mailto:hi@extr.ee'
[[links]]
name = 'GitHub'
url = 'https://github.com/Extroonie'
[[links]]
name = 'Discord'
username = 'Extroonie'
[[links]]
name = 'Signal'
url = 'https://signal.me/#eu/ZowsAT4NJCOyeG4SrCxT_02-yCidvGI73x3GycgIt1SdfH3J_o1NpYBnFGZua8Lk'
[[links]]
name = 'Twitter'
url = 'https://twitter.com/Extroonie'

47
hugo.toml Normal file
View file

@ -0,0 +1,47 @@
baseURL = 'https://extroonie.com/'
locale = 'en-us'
title = 'Ishmaam Khan'
publishDir = 'dist'
enableRobotsTXT = true
disableHugoGeneratorInject = true
disableKinds = ['taxonomy', 'term']
summaryLength = 40
[params]
author = 'Ishmaam Khan'
description = "Hi! I’m Ishmaam. I write software and spend a lot of time thinking about science, philosophy, and whatever else catches my interest."
images = ['/images/Pale_Blue_Dot.png']
[outputs]
home = ['HTML', 'RSS']
section = ['HTML', 'RSS']
[outputFormats.RSS]
baseName = 'rss'
[markup.goldmark.extensions]
typographer = true
[markup.goldmark.extensions.passthrough]
enable = true
[markup.goldmark.extensions.passthrough.delimiters]
block = [['\[', '\]'], ['$$', '$$']]
inline = [['\(', '\)']]
[markup.goldmark.parser.attribute]
block = true
[markup.goldmark.renderer]
unsafe = false
[markup.highlight]
codeFences = false
[markup.tableOfContents]
startLevel = 2
endLevel = 3
ordered = false
[[module.mounts]]
source = 'static'
target = 'static'
[[module.mounts]]
source = 'node_modules/katex/dist'
target = 'static/vendor/katex'
files = ['katex.min.css', 'fonts/**']
[[module.mounts]]
source = 'node_modules/@fontsource-variable/newsreader/files'
target = 'static/fonts'
files = ['newsreader-latin-wght-normal.woff2', 'newsreader-latin-wght-italic.woff2']

6
layouts/404.html Normal file
View file

@ -0,0 +1,6 @@
{{ define "main" }}
<div class="shell">
<h1>Page not found</h1>
<p>This page isn’t here. Head back to <a href="/">the homepage</a>.</p>
</div>
{{ end }}

View file

@ -0,0 +1,3 @@
<h{{ .Level }} id="{{ .Anchor | safeURL }}" tabindex="-1">
<a class="heading-link" href="#{{ .Anchor | safeURL }}">{{ .Text | safeHTML }}<span class="heading-anchor" aria-hidden="true">#</span></a>
</h{{ .Level }}>

View file

@ -0,0 +1,7 @@
{{- .Page.Store.Set "hasMath" true -}}
{{- $math := transform.ToMath .Inner (dict "output" "htmlAndMathml" "displayMode" (eq .Type "block") "strict" "warn") -}}
{{- if eq .Type "block" -}}
<div class="math-display" tabindex="0" aria-label="Mathematical equation">{{ $math }}</div>
{{- else -}}
{{ $math }}
{{- end -}}

View file

@ -0,0 +1,73 @@
{{- $content := .Content -}}
{{- $contents := partial "contents.html" . -}}
{{- .Store.Set "contents" $contents -}}
{{- $hasContents := and .IsPage (eq .Section "writing") (ge (len (findRE `<a href=` $contents)) 2) -}}
{{- .Store.Set "hasContents" $hasContents -}}
<!doctype html>
<html lang="en" id="top">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="color-scheme" content="light dark" />
{{ $theme := resources.Get "js/theme-init.js" | minify }}<script>{{ $theme.Content | safeJS }}</script>
{{ $title := partial "text.html" .Title | plainify | htmlUnescape }}
{{ $description := partial "text.html" (.Description | default site.Params.description) | plainify | htmlUnescape }}
{{ $documentTitle := $title }}
{{ if not .IsHome }}{{ $documentTitle = printf "%s · %s" $title site.Title }}{{ end }}
<title>{{ $documentTitle }}</title>
<meta name="description" content="{{ $description }}" />
<meta name="author" content="{{ site.Params.author }}" />
{{ if site.Params.preview }}<meta name="robots" content="noindex, nofollow" />{{ end }}
<link rel="license" href="https://creativecommons.org/publicdomain/zero/1.0/" />
<link rel="canonical" href="{{ .Permalink }}" />
<meta property="og:title" content="{{ $title }}" />
<meta property="og:description" content="{{ $description }}" />
<meta property="og:url" content="{{ .Permalink }}" />
{{ $type := cond (and .IsPage (eq .Section "writing")) "article" "website" }}
<meta property="og:type" content="{{ $type }}" />
<meta property="og:image" content="{{ (index site.Params.images 0) | absURL }}" />
<meta name="twitter:card" content="summary" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<link rel="apple-touch-icon" href="/apple-touch-icon.png" />
<link rel="preload" href="/fonts/newsreader-latin-wght-normal.woff2" as="font" type="font/woff2" crossorigin />
{{ $css := resources.Get "css/style.css" | minify | fingerprint }}
<link rel="stylesheet" href="{{ $css.RelPermalink }}" integrity="{{ $css.Data.Integrity }}" />
{{ range .AlternativeOutputFormats }}
{{ if eq .Name "RSS" }}
<link
rel="alternate"
type="application/rss+xml"
href="{{ .Permalink }}"
title="{{ site.Title }} — Writing"
/>
{{ end }}
{{ end }}
{{ if or (.Store.Get "hasMath") .Params.math }}
<link rel="stylesheet" href="/vendor/katex/katex.min.css" />
{{ end }}
{{ partial "structured-data.html" . }}
</head>
<body
class="{{ if .IsHome }}
home-page
{{ else if .IsPage }}
article-page
{{ else }}
archive-page
{{ end }}{{ if $hasContents }}has-contents{{ end }}"
>
{{ partial "header.html" . }}
<main id="main" tabindex="-1">{{ block "main" . }}{{ end }}</main>
{{ partial "footer.html" . }}
{{ $js := resources.Get "js/main.js" | minify | fingerprint }}
<script src="{{ $js.RelPermalink }}" integrity="{{ $js.Data.Integrity }}" defer></script>
{{ if $hasContents }}
{{ $navigation := resources.Get "js/contents.js" | minify | fingerprint }}
<script src="{{ $navigation.RelPermalink }}" integrity="{{ $navigation.Data.Integrity }}" defer></script>
{{ end }}
{{ if strings.Contains $content "class=\"footnote-ref\"" }}
{{ $footnotes := resources.Get "js/footnotes.js" | minify | fingerprint }}
<script src="{{ $footnotes.RelPermalink }}" integrity="{{ $footnotes.Data.Integrity }}" defer></script>
{{ end }}
</body>
</html>

7
layouts/_default/rss.xml Normal file
View file

@ -0,0 +1,7 @@
{{- $pages := partialCached "writing.html" . -}}
{{- if and (not .IsHome) (ne .Section "writing") }}{{ $pages = .RegularPages }}{{ end -}}
{{- with .Params.filterKind }}{{ $pages = where $pages "Params.format" . }}{{ end -}}
{{- printf "<?xml version=\"1.0\" encoding=\"utf-8\" standalone=\"yes\"?>" | safeHTML }}
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom"><channel><title>{{ site.Title }} — {{ with .Params.filterKind }}{{ if eq . "essay" }}Essays{{ else }}Posts{{ end }}{{ else }}Writing{{ end }}</title><link>{{ site.BaseURL }}</link><description>{{ partial "text.html" (.Description | default site.Params.description) | plainify | htmlUnescape }}</description><language>{{ site.Language.Locale }}</language>{{ with .OutputFormats.Get "RSS" }}<atom:link href="{{ .Permalink }}" rel="self" type="application/rss+xml"/>{{ end }}
{{ range first 30 $pages.ByDate.Reverse }}<item><title>{{ partial "text.html" .Title | plainify | htmlUnescape }}</title><link>{{ .Permalink }}</link><guid>{{ .Permalink }}</guid><pubDate>{{ .Date.Format "Mon, 02 Jan 2006 15:04:05 -0700" }}</pubDate><description>{{ partial "text.html" (.Description | default (.Summary | plainify)) | plainify | htmlUnescape }}</description></item>{{ end }}
</channel></rss>

40
layouts/index.html Normal file
View file

@ -0,0 +1,40 @@
{{ define "main" }}
<div class="shell">
<section class="intro" aria-label="Introduction">
<p>{{ partial "text.html" .Params.introduction }}</p>
{{ partial "contacts.html" . }}
<details class="biography" id="about">
<summary>
<span class="more-label">More about me…</span><span class="less-label">Less about me</span>
</summary>
<div class="biography-content">
<div class="contents">
<span>Contents</span>{{ .Store.Get "contents" }}
</div>
<div class="prose home-prose">{{ .Content }}</div>
</div>
</details>
</section>
{{ $writing := partialCached "writing.html" . }}
{{ range slice (dict "kind" "post" "title" "Posts" "id" "posts") (dict "kind" "essay" "title" "Essays" "id" "essays") }}
{{ $pages := where $writing "Params.format" .kind }}
{{ if $pages }}
<section class="writing-section" aria-labelledby="{{ .id }}">
<div class="section-heading">
<h2 id="{{ .id }}" tabindex="-1">
<a class="heading-link" href="#{{ .id }}"
>{{ .title }}<span class="heading-anchor" aria-hidden="true">#</span></a
>
</h2>
<a class="rss-link" href="/writing/{{ .id }}/rss.xml" aria-label="{{ .title }} RSS feed">RSS</a>
</div>
<ul class="writing-list">
{{ range $pages.ByDate.Reverse }}
<li><a href="{{ .RelPermalink }}">{{ partial "text.html" .Title }}</a></li>
{{ end }}
</ul>
</section>
{{ end }}
{{ end }}
</div>
{{ end }}

View file

@ -0,0 +1,19 @@
<div class="contact-links" aria-label="Contact and profiles">
<span>Reach out:</span>{{ range hugo.Data.contacts.links }}
{{ if .username }}
<span class="discord-contact"
><button
hidden
class="contact-link"
type="button"
data-copy="{{ .username }}"
aria-label="Copy Discord username: {{ .username }}"
>
{{ .name }}</button
><span class="discord-fallback">{{ .name }}: <span>{{ .username }}</span></span></span
>
{{ else }}
<a class="contact-link" href="{{ .url }}">{{ .name }}</a>
{{ end }}
{{ end }}
</div>

View file

@ -0,0 +1,9 @@
{{- $contents := .TableOfContents -}}
{{- if and .IsPage (eq .Section "writing") .Params.abstract -}}
{{- if strings.Contains $contents "<ul>" -}}
{{- $contents = replace $contents "<ul>" `<ul><li><a href="#abstract">Abstract</a></li>` 1 -}}
{{- else -}}
{{- $contents = `<nav id="TableOfContents"><ul><li><a href="#abstract">Abstract</a></li></ul></nav>` -}}
{{- end -}}
{{- end -}}
{{- return ($contents | replaceRE `<nav id="TableOfContents">` `<nav id="TableOfContents" aria-label="Contents">` | safeHTML) -}}

View file

@ -0,0 +1 @@
<div class="notice" role="status" aria-live="polite" aria-atomic="true"></div>

View file

@ -0,0 +1,11 @@
<a class="skip-link" href="#main">Skip to content</a>
<header class="site-header shell">
{{ if .IsHome }}
<h1 class="site-name"><a href="/">{{ site.Title }}</a></h1>
{{ else }}
<a class="site-name" href="/">{{ site.Title }}</a>
{{ end }}
<button class="theme-toggle" type="button" role="switch" aria-checked="false" aria-label="Dark mode" hidden>
<span class="toggle-thumb">{{ partial "icon.html" . }}</span>
</button>
</header>

View file

@ -0,0 +1,15 @@
<svg
class="ui-icon"
width="18"
height="18"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="1.5"
stroke-linecap="round"
stroke-linejoin="round"
aria-hidden="true"
>
<circle cx="12" cy="12" r="4" />
<path d="M12 2v2m0 16v2M2 12h2m16 0h2M5 5l1.5 1.5m11 11L19 19M5 19l1.5-1.5m11-11L19 5" />
</svg>

After

Width:  |  Height:  |  Size: 327 B

View file

@ -0,0 +1,7 @@
{{ $data := dict "@context" "https://schema.org" "@type" "WebSite" "name" site.Title "url" site.BaseURL }}
{{ if .IsHome }}
{{ $data = dict "@context" "https://schema.org" "@type" "Person" "name" site.Params.author "url" .Permalink }}
{{ else if and .IsPage (eq .Section "writing") }}
{{ $data = dict "@context" "https://schema.org" "@type" "BlogPosting" "headline" (partial "text.html" .Title | plainify | htmlUnescape) "datePublished" (.Date.Format "2006-01-02T15:04:05Z07:00") "url" .Permalink "author" (dict "@type" "Person" "name" site.Params.author) }}
{{ end }}
<script type="application/ld+json">{{ $data | jsonify | safeJS }}</script>

View file

@ -0,0 +1 @@
{{- return (. | markdownify) -}}

View file

@ -0,0 +1,7 @@
{{- $pages := where site.RegularPages "Section" "writing" -}}
{{- range $pages -}}
{{- if not (in (slice "post" "essay") .Params.format) -}}
{{- errorf "%s: format must be post or essay" .File.Path -}}
{{- end -}}
{{- end -}}
{{- return $pages -}}

3
layouts/robots.txt Normal file
View file

@ -0,0 +1,3 @@
User-agent: *
{{ if site.Params.preview }}Disallow: /{{ else }}Allow: /
Sitemap: {{ "sitemap.xml" | absURL }}{{ end }}

View file

@ -0,0 +1 @@
{{- $id := .Get 0 -}}{{- $label := .Get 1 | default "equation" -}}<a href="#eq-{{ $id | urlize }}">{{ $label }}</a>

View file

@ -0,0 +1,14 @@
{{- .Page.Store.Set "hasMath" true -}}
{{- $id := .Get "id" -}}
{{- if not $id }}{{ errorf "equation requires an id: %s" .Position }}{{ end -}}
{{- $count := .Page.Store.Get "equationCount" | default 0 -}}
{{- $number := add $count 1 -}}{{- .Page.Store.Set "equationCount" $number -}}
{{- with .Get "number" }}{{ $number = . }}{{ end -}}
<div class="numbered-equation" id="eq-{{ $id | urlize }}">
<div class="math-display" tabindex="0" aria-label="Equation {{ $number }}">
{{ transform.ToMath .Inner (dict "output" "htmlAndMathml" "displayMode" true "strict" "warn") }}
</div>
<a class="equation-number" href="#eq-{{ $id | urlize }}" aria-label="Link to equation {{ $number }}"
>({{ $number }})</a
>
</div>

18
layouts/writing/list.html Normal file
View file

@ -0,0 +1,18 @@
{{- $kind := .Params.filterKind | default "post" -}}
{{- $pages := where (partialCached "writing.html" .) "Params.format" $kind -}}
{{- $target := "/" -}}
{{- if $pages -}}
{{- $target = printf "/#%s" (cond (eq $kind "essay") "essays" "posts") -}}
{{- end -}}
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="robots" content="noindex" />
<meta http-equiv="refresh" content="0;url={{ $target }}" />
<title>{{ .Title }} · {{ site.Title }}</title>
</head>
<body>
<a href="{{ $target }}">Read my writing on the homepage.</a>
</body>
</html>

View file

@ -0,0 +1,44 @@
{{ define "main" }}
{{ $hasContents := .Store.Get "hasContents" }}
<article class="shell">
<header class="article-intro">
<h1>{{ partial "text.html" .Title }}</h1>
{{ with .Params.subtitle }}<p class="subtitle">{{ partial "text.html" . }}</p>{{ end }}
<div class="publication">
<time datetime="{{ .Date.Format "2006-01-02" }}">{{ .Date.Format "January 2, 2006" }}</time>
{{ if $hasContents }}
<button class="contents-reopen" aria-controls="article-navigation" aria-expanded="false" hidden>
Contents
</button>
{{ end }}
</div>
</header>
{{ if $hasContents }}
<details class="article-contents">
<summary aria-controls="article-navigation">Contents</summary>
<div id="article-navigation">
<div class="contents-heading" hidden>
<span>Contents</span>
<button class="contents-close" aria-label="Close contents">
<span aria-hidden="true">×</span>
</button>
</div>
{{ .Store.Get "contents" }}
</div>
</details>
{{ end }}
<div class="prose article-prose">
{{ with .Params.abstract }}
<section class="abstract">
<h2 id="abstract" tabindex="-1">
<a class="heading-link" href="#abstract"
>Abstract<span class="heading-anchor" aria-hidden="true">#</span></a
>
</h2>
<p>{{ partial "text.html" . }}</p>
</section>
{{ end }}{{ .Content }}
{{ with .Params.pdf }}<p><a href="{{ . }}">Download PDF</a></p>{{ end }}
</div>
</article>
{{ end }}

26
package.json Normal file
View file

@ -0,0 +1,26 @@
{
"name": "extroonie",
"private": true,
"version": "1.0.0",
"license": "CC0-1.0",
"type": "module",
"scripts": {
"build": "bun scripts/build.mjs",
"check": "bun scripts/check.mjs",
"test": "bun scripts/test.mjs && bun scripts/test-contents.mjs && bun scripts/test-footnotes.mjs && bun scripts/test-preview.mjs",
"test:deploy": "bun scripts/test-deploy.mjs",
"format": "bun scripts/format.mjs --write",
"format:check": "bun scripts/format.mjs --check",
"dev": "bun scripts/preview.mjs"
},
"devDependencies": {
"@fontsource-variable/newsreader": "5.3.0",
"katex": "0.18.7",
"prettier": "3.9.8",
"prettier-plugin-go-template": "0.0.15"
},
"packageManager": "bun@1.4.2",
"overrides": {
"ulid": "3.0.2"
}
}

16
scripts/build.mjs Normal file
View file

@ -0,0 +1,16 @@
import { rmSync } from 'node:fs';
import { spawnSync } from 'node:child_process';
import { siteBaseURL } from './config.mjs';
const run = (command, args) => {
const result = spawnSync(command, args, {
stdio: 'inherit',
env: process.env,
});
if (result.error) throw result.error;
if (result.status !== 0) process.exit(result.status || 1);
};
const hugo = process.env.HUGO_BIN || 'hugo';
const baseURL = siteBaseURL();
rmSync('dist', { recursive: true, force: true });
run(hugo, ['--minify', '--baseURL', baseURL.href]);
run(process.execPath, ['scripts/check.mjs']);

97
scripts/check.mjs Normal file
View file

@ -0,0 +1,97 @@
import { readFileSync, existsSync, readdirSync, statSync } from 'node:fs';
import { join, relative } from 'node:path';
import assert from 'node:assert/strict';
import { siteBaseURL } from './config.mjs';
function walk(dir) {
return readdirSync(dir, { withFileTypes: true }).flatMap((entry) => {
const path = join(dir, entry.name);
return entry.isDirectory() ? walk(path) : [path];
});
}
export function checkSite(directory = 'dist') {
const files = walk(directory);
const pages = new Map();
for (const file of files.filter((file) => file.endsWith('.html'))) {
const html = readFileSync(file, 'utf8');
const page = { ids: new Set(), links: [], controls: [], structuredData: [] };
new HTMLRewriter()
.on('*', {
element(element) {
const id = element.getAttribute('id');
if (id) {
assert(!page.ids.has(id), `${file}: duplicate id ${id}`);
page.ids.add(id);
}
for (const name of ['src', 'href']) {
const value = element.getAttribute(name);
if (value) page.links.push(value);
}
for (const name of ['aria-controls', 'aria-labelledby', 'aria-describedby']) {
const value = element.getAttribute(name);
if (value) page.controls.push(...value.trim().split(/\s+/));
}
},
})
.on('script[type="application/ld+json"]', {
element() {
page.structuredData.push('');
},
text(chunk) {
page.structuredData[page.structuredData.length - 1] += chunk.text;
},
})
.transform(html);
for (const json of page.structuredData) JSON.parse(json);
for (const id of page.controls) assert(page.ids.has(id), `${file}: missing accessible control target ${id}`);
assert(!html.includes('github.githubassets.com'), `${file}: external GitHub CSS`);
pages.set(file, page);
}
const origin = siteBaseURL();
function checkLink(file, value) {
const base = new URL(
relative(directory, file)
.replaceAll('\\', '/')
.replace(/index\.html$/, ''),
origin,
);
const url = new URL(value, base);
if (url.origin !== origin.origin) return;
let target = join(directory, decodeURIComponent(url.pathname));
assert(existsSync(target), `${file}: broken asset or link ${value}`);
if (statSync(target).isDirectory()) target = join(target, 'index.html');
assert(existsSync(target), `${file}: missing index for ${value}`);
if (url.hash && pages.has(target)) {
const id = decodeURIComponent(url.hash.slice(1));
assert(pages.get(target).ids.has(id), `${file}: missing fragment ${value}`);
}
}
for (const [file, page] of pages) for (const link of page.links) checkLink(file, link);
for (const file of files.filter((file) => file.endsWith('.css'))) {
for (const match of readFileSync(file, 'utf8').matchAll(/url\(\s*["']?([^\s"')]+)["']?\s*\)/g))
checkLink(file, match[1]);
}
return pages;
}
if (import.meta.main) {
const pages = checkSite();
for (const route of [
'index.html',
'writing/index.html',
'writing/posts/index.html',
'writing/essays/index.html',
'404.html',
'rss.xml',
'writing/rss.xml',
'writing/posts/rss.xml',
'writing/essays/rss.xml',
'sitemap.xml',
'robots.txt',
])
assert(existsSync(`dist/${route}`), `Missing route ${route}`);
for (const id of ['computers', 'science', 'philosophy', 'other-things'])
assert(pages.get('dist/index.html').ids.has(id), `Missing homepage anchor ${id}`);
console.log(`Checked ${pages.size} HTML pages, links, fragments, control targets, CSS assets, and JSON-LD.`);
}

18
scripts/config.mjs Normal file
View file

@ -0,0 +1,18 @@
import { readFileSync } from 'node:fs';
export function siteBaseURL() {
const { baseURL } = Bun.TOML.parse(readFileSync(new URL('../hugo.toml', import.meta.url), 'utf8'));
const url = new URL(process.env.SITE_BASE_URL || baseURL);
if (
!['http:', 'https:'].includes(url.protocol) ||
url.pathname !== '/' ||
url.search ||
url.hash ||
url.username ||
url.password
)
throw new Error(
'SITE_BASE_URL/baseURL must be an HTTP(S) origin without a subpath, query, fragment, or credentials.',
);
return url;
}

19
scripts/format.mjs Normal file
View file

@ -0,0 +1,19 @@
import { spawnSync } from 'node:child_process';
const result = spawnSync(
process.execPath,
[
'node_modules/prettier/bin/prettier.cjs',
...process.argv.slice(2),
'assets/**/*.{css,js}',
'scripts/*.mjs',
'*.json',
'*.yaml',
'.github/workflows/*.yml',
'.prettierrc.json',
'layouts/**/*.html',
],
{ stdio: 'inherit' },
);
if (result.error) throw result.error;
process.exit(result.status ?? 1);

32
scripts/preview.mjs Normal file
View file

@ -0,0 +1,32 @@
import { spawn } from 'node:child_process';
import { parseArgs } from 'node:util';
const { values } = parseArgs({
options: {
host: { type: 'string', default: '127.0.0.1' },
port: { type: 'string', default: process.env.DEV_PORT || '1313' },
drafts: { type: 'boolean', default: false },
},
});
const server = spawn(
process.env.HUGO_BIN || 'hugo',
[
'server',
'--bind',
values.host,
'--port',
values.port,
'--baseURL',
`http://localhost:${values.port}/`,
'--appendPort=false',
'--disableLiveReload',
'--disableFastRender',
...(values.drafts ? ['--buildDrafts'] : []),
],
{ stdio: 'inherit' },
);
for (const signal of ['SIGINT', 'SIGTERM']) process.on(signal, () => server.kill(signal));
server.on('error', (error) => {
console.error(`Unable to start Hugo: ${error.message}`);
process.exit(1);
});
server.on('exit', (code, signal) => process.exit(code ?? (signal ? 1 : 0)));

109
scripts/test-contents.mjs Normal file
View file

@ -0,0 +1,109 @@
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import { runInNewContext } from 'node:vm';
class Element {
listeners = {};
attributes = {};
hidden = false;
open = false;
classes = new Set();
classList = { toggle: (name, enabled) => (enabled ? this.classes.add(name) : this.classes.delete(name)) };
addEventListener(name, fn) {
this.listeners[name] = fn;
}
setAttribute(name, value) {
this.attributes[name] = value;
}
removeAttribute(name) {
delete this.attributes[name];
}
focus() {
this.focused = true;
}
emit(name, event = {}) {
this.listeners[name]?.({ button: 0, ...event });
}
}
for (const initiallyWide of [false, true]) {
const contents = new Element();
const reopen = new Element();
const close = new Element();
const title = new Element();
const summary = new Element();
const media = new Element();
media.matches = initiallyWide;
const links = [new Element(), new Element()];
links.forEach((link, i) => {
link.hash = `#section-${i}`;
});
const headings = links.map(() => new Element());
headings.forEach((heading, i) => {
heading.getBoundingClientRect = () => ({ top: i * 500 });
});
contents.querySelector = (selector) =>
({ '.contents-close': close, '.contents-heading': title, summary })[selector];
contents.querySelectorAll = () => links;
const window = new Element();
runInNewContext(readFileSync('assets/js/contents.js', 'utf8'), {
document: {
querySelector: (selector) => (selector === '.article-contents' ? contents : reopen),
getElementById: (id) => headings[Number(id.split('-').at(-1))],
},
window,
matchMedia: (query) => {
assert.equal(query, '(min-width: 64rem)');
return media;
},
requestAnimationFrame: (fn) => fn(),
});
assert.equal(contents.open, initiallyWide);
assert.equal(reopen.hidden, true);
assert.equal(title.hidden, !initiallyWide);
media.matches = true;
media.emit('change');
assert(contents.open);
close.emit('click');
assert(!contents.open && !reopen.hidden && reopen.focused);
assert.equal(reopen.attributes['aria-expanded'], 'false');
reopen.emit('click');
assert(contents.open && reopen.hidden && close.focused);
contents.emit('keydown', { key: 'Escape' });
assert(!contents.open && !reopen.hidden, 'Escape must close the sidebar');
reopen.emit('click');
contents.emit('click', { target: { closest: () => links[1] } });
assert(contents.open, 'Desktop section navigation must leave the sidebar visible');
assert(headings[1].focused);
close.emit('click');
media.matches = false;
media.emit('change');
assert(!contents.open && reopen.hidden && title.hidden);
assert(!contents.classes.has('sidebar'));
summary.emit('click', { preventDefault() {} });
assert(contents.open);
media.matches = true;
media.emit('change');
assert(!contents.open, 'Resizing must preserve the desktop closed choice');
media.matches = false;
media.emit('change');
assert(contents.open, 'Resizing must preserve the mobile open choice');
for (const modifier of [
{ ctrlKey: true },
{ metaKey: true },
{ shiftKey: true },
{ altKey: true },
{ button: 1 },
]) {
contents.emit('click', { ...modifier, target: { closest: () => links[0] } });
assert(contents.open, 'Modified navigation must not change the current disclosure');
assert(!headings[0].focused, 'Modified navigation must not move focus in the current page');
}
contents.emit('click', { target: { closest: () => links[0] } });
assert(!contents.open, 'Mobile section navigation should collapse the disclosure');
assert.equal(links[0].attributes['aria-current'], 'location');
assert.equal(links[1].attributes['aria-current'], undefined);
}
console.log(
'Passed: contents defaults, close/reopen, focus, responsive state, section navigation, and current section.',
);

190
scripts/test-deploy.mjs Normal file
View file

@ -0,0 +1,190 @@
import assert from 'node:assert/strict';
import {
mkdtempSync,
mkdirSync,
copyFileSync,
writeFileSync,
readFileSync,
existsSync,
rmSync,
statSync,
} from 'node:fs';
import { tmpdir } from 'node:os';
import { join, resolve } from 'node:path';
import { spawnSync } from 'node:child_process';
const root = mkdtempSync(join(tmpdir(), 'extroonie-deploy-'));
try {
const bin = join(root, 'bin');
mkdirSync(bin);
// Git is real (including the remote and fast-forward). Only Docker is simulated.
writeFileSync(
join(bin, 'docker'),
`#!/bin/sh
set -eu
snapshot=''
if [ "\${1:-}" = compose ] && [ "\${2:-}" = --project-directory ]; then
snapshot=$5
shift 5
set -- compose "$@"
fi
printf '%s %s [%s]\\n' "\${SITE_IMAGE:-unset}" "$*" "$snapshot" >> "$DEPLOY_LOG"
case "$*" in
'compose version'|'info') exit 0 ;;
'compose ps -q site')
case "$snapshot" in
*candidate.yaml) echo current-container ;;
*current.yaml) echo restored-container ;;
*) [ "$SCENARIO" = first-deploy ] || echo previous-container ;;
esac ;;
'compose config --images site') echo extroonie:local ;;
'compose config site') printf 'name: test\\nservices:\\n site:\\n image: %s\\n environment:\\n RELEASE: %s\\n' "$SITE_IMAGE" "$(cat compose.yaml)" ;;
'compose port site 8080') echo "127.0.0.1:\${SITE_PORT:-8080}" ;;
'inspect --format {{.Image}} previous-container') echo sha256:previous ;;
'compose build --pull site')
grep -q 'RELEASE: new-config' "$snapshot"
[ "$SCENARIO" != build-failure ] ;;
'compose up '*)
case "$snapshot" in
*previous.yaml)
grep -q 'image: sha256:previous' "$snapshot"
grep -q 'RELEASE: old-config' "$snapshot"
[ "$SCENARIO" != rollback-failure ] ;;
*candidate.yaml)
case "$SCENARIO" in health-failure|cached-rollback|rollback-failure) exit 1 ;; esac ;;
*) exit 2 ;;
esac ;;
'compose logs '*) echo 'health check failed' ;;
'image inspect '*) echo sha256:current ;;
'image tag sha256:current extroonie:local'|'image tag sha256:previous extroonie:local') exit 0 ;;
'image ls '*) printf 'extroonie:current sha256:current\\nextroonie:previous sha256:previous\\nextroonie:old sha256:old\\nunrelated:old sha256:unrelated\\n' ;;
'image rm extroonie:old') exit 0 ;;
*) echo "Unexpected Docker command: $*" >&2; exit 2 ;;
esac
`,
{ mode: 0o755 },
);
function git(cwd, ...args) {
const result = spawnSync('git', args, {
cwd,
encoding: 'utf8',
env: {
...process.env,
GIT_AUTHOR_NAME: 'Test',
GIT_AUTHOR_EMAIL: 'test@example.test',
GIT_COMMITTER_NAME: 'Test',
GIT_COMMITTER_EMAIL: 'test@example.test',
},
});
assert.equal(result.status, 0, result.stderr);
return result.stdout.trim();
}
for (const scenario of [
'success',
'first-deploy',
'build-failure',
'health-failure',
'cached-rollback',
'rollback-failure',
'dirty',
'diverged',
]) {
const folder = join(root, scenario);
const remote = join(folder, 'remote.git');
const seed = join(folder, 'seed');
const checkout = join(folder, 'checkout');
mkdirSync(folder);
git(folder, 'init', '--bare', remote);
git(folder, 'init', '-b', 'main', seed);
writeFileSync(join(seed, '.gitignore'), '/deploy.sh\n');
writeFileSync(join(seed, 'compose.yaml'), 'old-config\n');
git(seed, 'add', '.');
git(seed, 'commit', '-m', 'Initial');
git(seed, 'remote', 'add', 'origin', remote);
git(seed, 'push', '-u', 'origin', 'main');
git(folder, 'clone', '-b', 'main', remote, checkout);
copyFileSync(resolve('deploy.sh'), join(checkout, 'deploy.sh'));
assert.equal(git(checkout, 'check-ignore', 'deploy.sh'), 'deploy.sh');
const state = join(checkout, '.git/extroonie-deploy');
if (scenario === 'cached-rollback') {
mkdirSync(state);
writeFileSync(join(state, 'container'), 'previous-container\n');
writeFileSync(join(state, 'current.yaml'), 'image: sha256:previous\nRELEASE: old-config\n');
writeFileSync(join(checkout, 'compose.yaml'), 'failed-config\n');
git(checkout, 'add', '.');
git(checkout, 'commit', '-m', 'Earlier failed deployment');
git(checkout, 'push');
git(seed, 'pull', '--ff-only');
}
writeFileSync(join(seed, 'update.txt'), 'new release\n');
writeFileSync(join(seed, 'compose.yaml'), 'new-config\n');
git(seed, 'add', '.');
git(seed, 'commit', '-m', 'Update');
git(seed, 'push');
if (scenario === 'dirty') writeFileSync(join(checkout, 'local.txt'), 'keep me');
if (scenario === 'diverged') {
writeFileSync(join(checkout, 'local.txt'), 'local commit');
git(checkout, 'add', '.');
git(checkout, 'commit', '-m', 'Local change');
}
const log = join(folder, 'docker.log');
const run = spawnSync('sh', ['./deploy.sh'], {
cwd: checkout,
encoding: 'utf8',
env: {
...process.env,
PATH: `${bin}:${process.env.PATH}`,
DEPLOY_LOG: log,
SCENARIO: scenario,
},
});
const calls = readFileSync(log, 'utf8');
if (['success', 'first-deploy'].includes(scenario)) assert.equal(run.status, 0, run.stdout + run.stderr);
else assert.notEqual(run.status, 0, 'Expected deployment failure');
assert(!existsSync(join(checkout, '.git/extroonie-deploy.lock')), 'Lock was not released');
if (scenario === 'dirty' || scenario === 'diverged') {
assert(!calls.includes('compose build'), 'Built despite unsafe checkout');
assert(existsSync(join(checkout, 'local.txt')), 'Local work lost');
continue;
}
assert(existsSync(join(checkout, 'update.txt')), 'Remote changes were not pulled');
if (scenario === 'build-failure') {
assert(!calls.includes('compose up'), 'Stopped serving after build failure');
assert(
readFileSync(join(state, 'current.yaml'), 'utf8').includes('RELEASE: old-config'),
'Initial rollback configuration lost after failed build',
);
}
if (['health-failure', 'cached-rollback', 'rollback-failure'].includes(scenario)) {
assert(calls.includes('previous.yaml]'), 'Rollback not attempted');
assert(!calls.includes('image rm'), 'Cleanup ran after failed deployment');
const recovery = readFileSync(join(state, 'current.yaml'), 'utf8');
assert(recovery.includes('RELEASE: old-config'), 'Recovery configuration overwritten');
assert(recovery.includes('image: sha256:previous'), 'Recovery image lost');
if (scenario !== 'rollback-failure') {
assert(calls.includes('image tag sha256:previous extroonie:local'), 'Stable tag was not restored');
assert.equal(readFileSync(join(state, 'container'), 'utf8').trim(), 'restored-container');
}
}
if (scenario === 'success') {
assert(calls.indexOf('compose build') < calls.indexOf('compose up'));
assert(calls.indexOf('compose up') < calls.indexOf('image rm'));
assert(calls.includes('image rm extroonie:old'));
assert(!calls.includes('image rm extroonie:previous'));
assert(!calls.includes('image rm unrelated'));
assert(calls.includes('image tag sha256:current extroonie:local'), 'Default Compose image would be stale');
assert(readFileSync(join(state, 'current.yaml'), 'utf8').includes('RELEASE: new-config'));
assert.equal(readFileSync(join(state, 'container'), 'utf8').trim(), 'current-container');
assert.equal(
statSync(join(state, 'current.yaml')).mode & 0o777,
0o600,
'Snapshot may contain private configuration',
);
}
}
console.log(
'Passed: simulated first deployment, stable image tagging, saved-config rollback, repeated/failed rollback, scoped cleanup, and real Git safety checks.',
);
} finally {
rmSync(root, { recursive: true, force: true });
}

View file

@ -0,0 +1,90 @@
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import { runInNewContext } from 'node:vm';
let focused;
class Element {
listeners = {};
attributes = {};
children = [];
style = {};
offsetWidth = 240;
offsetHeight = 160;
addEventListener(name, listener) {
this.listeners[name] = listener;
}
emit(name, event = {}) {
this.listeners[name]?.(event);
}
setAttribute(name, value) {
this.attributes[name] = value;
}
removeAttribute(name) {
delete this.attributes[name];
}
replaceChildren(...children) {
this.children = children;
}
contains(element) {
return this.children.includes(element);
}
focus(options) {
focused = this;
this.focusOptions = options;
this.emit('focus');
}
}
const reference = new Element();
reference.hash = '#fn:1';
reference.textContent = '1';
reference.matches = () => true;
reference.getBoundingClientRect = () => ({ left: 960, top: 730, bottom: 750 });
const superscript = new Element();
superscript.after = () => {};
reference.closest = () => superscript;
const link = new Element();
const note = new Element();
note.cloneNode = () => ({ childNodes: [link], querySelectorAll: () => [] });
const preview = new Element();
const document = new Element();
Object.defineProperty(document, 'activeElement', { get: () => focused });
document.querySelectorAll = () => [reference];
document.createElement = () => preview;
document.getElementById = (id) => (id === 'fn:1' ? note : null);
document.body = { append() {} };
const window = new Element();
runInNewContext(readFileSync('assets/js/footnotes.js', 'utf8'), {
document,
window,
innerWidth: 1000,
innerHeight: 800,
setTimeout,
clearTimeout,
});
assert(preview.hidden);
reference.emit('pointerenter', { pointerType: 'touch' });
assert(preview.hidden, 'Touch should retain native endnote navigation');
reference.focus();
assert(!preview.hidden && reference.attributes['aria-controls'] === 'footnote-preview');
assert.equal(preview.style.left, '744px');
assert.equal(preview.style.top, '562px');
link.focus();
window.emit('scroll');
assert(!preview.hidden, 'Scrolling must not hide the focused preview');
window.emit('resize');
assert(preview.hidden && focused === reference, 'Resize must not leave focus in a hidden preview');
assert.equal(reference.focusOptions.preventScroll, true);
assert.equal(reference.attributes['aria-controls'], undefined);
reference.focus();
link.focus();
document.emit('keydown', { key: 'Escape' });
assert(preview.hidden && focused === reference, 'Escape must return focus to the reference');
reference.focus();
const elsewhere = new Element();
elsewhere.focus();
window.emit('resize');
assert(preview.hidden && focused === elsewhere, 'Closing must not steal unrelated focus');
reference.focus();
reference.emit('click');
assert(preview.hidden, 'Endnote navigation must close the preview');
console.log('Passed: simulated footnote focus restoration, dismissal, touch fallback, and viewport positioning.');

70
scripts/test-preview.mjs Normal file
View file

@ -0,0 +1,70 @@
import assert from 'node:assert/strict';
import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join, resolve } from 'node:path';
import { spawn } from 'node:child_process';
import { createServer } from 'node:net';
import { once } from 'node:events';
import { setTimeout as delay } from 'node:timers/promises';
const temp = mkdtempSync(join(tmpdir(), 'extroonie-preview-'));
try {
writeFileSync(
join(temp, 'hugo.toml'),
"baseURL = 'https://example.test/'\ndisableKinds = ['taxonomy', 'term', 'rss', 'sitemap']\n",
);
mkdirSync(join(temp, 'content'));
mkdirSync(join(temp, 'layouts/_default'), { recursive: true });
writeFileSync(join(temp, 'layouts/index.html'), 'Preview homepage');
writeFileSync(join(temp, 'layouts/_default/single.html'), '{{ .Content }}');
writeFileSync(join(temp, 'content/draft.md'), '---\ntitle: Draft\ndraft: true\n---\nDraft preview content.\n');
for (const drafts of [false, true]) {
const socket = createServer().listen(0, '127.0.0.1');
await once(socket, 'listening');
const { port } = socket.address();
await new Promise((resolve) => socket.close(resolve));
const server = spawn(
process.execPath,
[resolve('scripts/preview.mjs'), ...(drafts ? ['--drafts'] : []), '--port', String(port)],
{
cwd: temp,
env: { ...process.env, HUGO_BIN: process.env.HUGO_BIN || 'hugo' },
stdio: ['ignore', 'pipe', 'pipe'],
},
);
let output = '';
server.stdout.on('data', (chunk) => {
output += chunk;
});
server.stderr.on('data', (chunk) => {
output += chunk;
});
try {
let ready = false;
for (let attempt = 0; attempt < 50; attempt++) {
assert.equal(server.exitCode, null, output);
try {
const response = await fetch(`http://127.0.0.1:${port}/`, { signal: AbortSignal.timeout(500) });
if (response.ok) {
ready = true;
break;
}
} catch {}
await delay(100);
}
assert(ready, `Preview did not become ready: ${output}`);
const response = await fetch(`http://127.0.0.1:${port}/draft/`, { signal: AbortSignal.timeout(1000) });
assert.equal(response.status, drafts ? 200 : 404, 'Draft preview flag was not honored');
if (drafts) assert((await response.text()).includes('Draft preview content.'));
} finally {
if (server.exitCode === null && server.signalCode === null) {
const closed = once(server, 'close');
server.kill('SIGTERM');
await closed;
}
}
}
console.log('Passed: real Hugo preview startup, configurable ports, opt-in drafts, and shutdown.');
} finally {
rmSync(temp, { recursive: true, force: true });
}

193
scripts/test.mjs Normal file
View file

@ -0,0 +1,193 @@
import assert from 'node:assert/strict';
import { mkdtempSync, cpSync, readFileSync, writeFileSync, mkdirSync, rmSync, existsSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { spawnSync } from 'node:child_process';
import { checkSite } from './check.mjs';
import { siteBaseURL } from './config.mjs';
const temp = mkdtempSync(join(tmpdir(), 'extroonie-test-'));
try {
cpSync('content', join(temp, 'content'), { recursive: true });
cpSync('tests/fixtures/math.md', join(temp, 'content/writing/math-test.md'));
cpSync('tests/fixtures/post.md', join(temp, 'content/writing/post-test.md'));
for (const [name, headings] of Object.entries({
'one-section': '# An in-body title\n\n## One section\n\n#### A minor heading',
'two-sections': '## First section\n\n### Second section',
'abstract-only': '',
'abstract-and-section': '## One section',
})) {
writeFileSync(
join(temp, `content/writing/${name}.md`),
`---\ntitle: ${name}\ndate: 2026-01-01\nformat: post\n${name.startsWith('abstract-') ? 'abstract: A short abstract.\n' : ''}---\n${headings}\n`,
);
}
const hugo = process.env.HUGO_BIN || 'hugo';
const build = (...args) =>
spawnSync(
hugo,
[
'--contentDir',
join(temp, 'content'),
'--destination',
join(temp, 'out'),
'--baseURL',
siteBaseURL().href,
...args,
],
{ encoding: 'utf8' },
);
const result = build();
assert.equal(result.status, 0, result.stdout + result.stderr);
checkSite(join(temp, 'out'));
const page = readFileSync(join(temp, 'out/writing/math-test/index.html'), 'utf8');
assert(page.includes('katex.min.css'), 'Math stylesheet missing');
assert(page.includes('katex-mathml'), 'Accessible build-time math missing');
assert(page.includes('eq-entropy'), 'Equation anchor missing');
assert(page.includes('href="#eq-entropy"'), 'Equation reference missing');
assert(!page.includes('>Published'), 'Publication label should not appear');
assert(!page.includes('>Updated'), 'Update label should not appear');
assert(page.includes('January 1, 2026'), 'Published date missing');
assert(page.includes('“quoted”'), 'Smart quotes missing');
assert(
page.includes('writer’s') || page.includes('writer&rsquo;s') || page.includes('writer&#39;s'),
'Smart apostrophe missing',
);
assert(
page.includes('<code>&quot;code&quot;</code>') ||
page.includes('<code>&#34;code&#34;</code>') ||
page.includes('<code>"code"</code>'),
'Code quotes changed: ' + page.match(/<code>[\s\S]*?<\/code>/g),
);
assert(!page.includes('katex.min.js'), 'Math should require no client JavaScript');
const home = readFileSync(join(temp, 'out/index.html'), 'utf8');
assert(home.includes('class="biography"'), 'Expandable biography missing');
assert(!/<details[^>]*\bopen\b/.test(home), 'Biography must start collapsed');
assert(home.includes('<a href="/">Ishmaam Khan</a>'), 'Homepage name must reset the URL');
new HTMLRewriter()
.on('nav nav', {
element() {
assert.fail('Contents must not nest navigation landmarks');
},
})
.transform(home);
assert(home.includes('/writing/posts/rss.xml'), 'Posts RSS missing');
assert(home.includes('/writing/essays/rss.xml'), 'Essays RSS missing');
const essays = readFileSync(join(temp, 'out/writing/essays/rss.xml'), 'utf8');
assert(essays.includes('/writing/math-test/'), 'Essay filtering failed');
assert(!essays.includes('/writing/nodejs-workspaces/'), 'Post leaked into essay feed');
assert(page.includes('/js/contents.'), 'Sectioned essay should load navigation');
assert(!home.includes('/js/contents.'), 'Homepage should not load article navigation');
assert(page.includes('aria-controls="article-navigation"'), 'Contents control missing');
assert(page.includes('aria-label="Close contents"'), 'Accessible sidebar close button missing');
assert(page.includes('class="contents-reopen"'), 'Sidebar reopening control missing');
assert(!home.includes('contents-reopen'), 'Homepage should not have sidebar controls');
assert(page.includes('class="subtitle"'), 'Essay subtitle missing');
assert(page.includes('href="#abstract">Abstract</a>'), 'Abstract missing from Contents');
for (const [name, expected] of [
['one-section', false],
['two-sections', true],
['abstract-only', false],
['abstract-and-section', true],
]) {
const article = readFileSync(join(temp, `out/writing/${name}/index.html`), 'utf8');
for (const marker of ['has-contents', 'class="article-contents"', 'class="contents-reopen"', '/js/contents.'])
assert.equal(
article.includes(marker),
expected,
`${name}: inconsistent Contents eligibility for ${marker}`,
);
assert(!/<details[^>]*\bopen\b/.test(article), 'Inline Contents must start collapsed');
if (name === 'abstract-and-section') assert(article.includes('href="#abstract">Abstract</a>'));
}
const postFixture = readFileSync(join(temp, 'out/writing/post-test/index.html'), 'utf8');
assert(!postFixture.includes('/js/contents.'), 'Unsectioned article loads unused navigation');
assert(postFixture.includes('An italic subtitle for a post'), 'Post subtitle missing');
assert(postFixture.includes('katex-mathml'), 'Post math missing');
for (const article of [page, postFixture]) {
assert(article.includes('class="footnote-ref"'), 'Footnote reference missing');
assert(article.includes('class="footnote-backref"'), 'Footnote return link missing');
assert(article.includes('/js/footnotes.'), 'Footnote previews missing');
}
assert(postFixture.includes('A second paragraph'), 'Multi-paragraph footnote missing');
assert(postFixture.match(/class="footnote-ref"/g).length === 2, 'Repeated footnote references missing');
assert(!home.includes('/js/footnotes.'), 'Homepage loads unused footnote script');
const post = readFileSync(join(temp, 'out/writing/nodejs-workspaces/index.html'), 'utf8');
assert(!post.includes('katex.min.css'), 'Non-math post loads math assets');
const rss = readFileSync(join(temp, 'out/writing/rss.xml'), 'utf8');
assert(rss.includes('A “quoted” title'), 'RSS typography inconsistent');
for (const section of ['writing', 'writing/posts', 'writing/essays']) {
const description = readFileSync(`content/${section}/_index.md`, 'utf8').match(/^description: "(.*)"$/m)[1];
assert(
readFileSync(join(temp, `out/${section}/rss.xml`), 'utf8').includes(
`<description>${description}</description>`,
),
`${section}: RSS description ignored`,
);
}
assert(page.includes('<pre><code class="language-js">'), 'Fenced code should use plain semantic markup');
assert(!page.includes('class="chroma"'), 'Unused syntax-token markup should not be generated');
for (const kind of ['posts', 'essays']) {
const archive = readFileSync(join(temp, `out/writing/${kind}/index.html`), 'utf8');
assert(archive.includes(`url=/#${kind}`), `Populated ${kind} archive should target its homepage section`);
}
for (const [shipped, upstream] of [
['static/licenses/katex.txt', 'node_modules/katex/LICENSE'],
['static/licenses/newsreader.txt', 'node_modules/@fontsource-variable/newsreader/LICENSE'],
])
assert.equal(readFileSync(shipped, 'utf8'), readFileSync(upstream, 'utf8'), `Outdated license: ${shipped}`);
const checks = join(temp, 'checks');
mkdirSync(checks);
const checkFile = join(checks, 'index.html');
writeFileSync(checkFile, '<h2 id="café">Heading</h2><a href="#caf%C3%A9">Link</a>');
checkSite(checks);
for (const [html, expected] of [
['<a href="#missing">Broken fragment</a>', /missing fragment/],
['<img src="missing.png">', /broken asset or link/],
['<h2 id="duplicate"></h2><h2 id="duplicate"></h2>', /duplicate id/],
['<button aria-controls="missing">Contents</button>', /missing accessible control target/],
]) {
writeFileSync(checkFile, html);
assert.throws(() => checkSite(checks), expected);
}
const originalURL = process.env.SITE_BASE_URL;
try {
process.env.SITE_BASE_URL = 'https://alternate.example/';
assert.equal(siteBaseURL().href, process.env.SITE_BASE_URL);
writeFileSync(checkFile, '<a href="https://alternate.example/missing">Broken absolute link</a>');
assert.throws(() => checkSite(checks), /broken asset or link/);
for (const url of [
'https://example.test/subpath/',
'file:///tmp/',
'https://example.test/?q=1',
'https://example.test/#hash',
'https://user:pass@example.test/',
]) {
process.env.SITE_BASE_URL = url;
assert.throws(siteBaseURL, /must be an HTTP\(S\) origin/);
}
} finally {
if (originalURL === undefined) delete process.env.SITE_BASE_URL;
else process.env.SITE_BASE_URL = originalURL;
}
const invalidFile = join(temp, 'content/writing/invalid.md');
for (const format of ['', 'format: typo\n']) {
writeFileSync(invalidFile, `---\ntitle: Invalid\ndate: 2026-01-01\n${format}---\nText.\n`);
const invalid = build();
assert.notEqual(invalid.status, 0, 'Invalid writing format must fail the build');
assert((invalid.stdout + invalid.stderr).includes('format must be post or essay'));
}
rmSync(invalidFile);
writeFileSync(
join(temp, 'content/writing/draft.md'),
'---\ntitle: Draft\ndate: 2026-01-01\nformat: post\ndraft: true\n---\nDraft text.\n',
);
assert.equal(build().status, 0);
assert(!existsSync(join(temp, 'out/writing/draft/index.html')), 'Draft leaked into the production build');
assert.equal(build('--buildDrafts').status, 0);
assert(existsSync(join(temp, 'out/writing/draft/index.html')), 'Draft preview omitted draft content');
console.log(
'Passed: math, equations, smart quotes, literal code, dates, subtitles, biography, filtered RSS, and conditional assets.',
);
} finally {
rmSync(temp, { recursive: true, force: true });
}

BIN
static/apple-touch-icon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 987 B

1
static/favicon.svg Normal file
View file

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64"><rect width="64" height="64" rx="16" fill="#25251f"/><circle cx="32" cy="32" r="13" fill="#eee3a5"/><circle cx="37" cy="27" r="4" fill="#faf6dc"/></svg>

After

Width:  |  Height:  |  Size: 213 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 526 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

121
static/licenses/cc0.txt Normal file
View file

@ -0,0 +1,121 @@
Creative Commons Legal Code
CC0 1.0 Universal
CREATIVE COMMONS CORPORATION IS NOT A LAW FIRM AND DOES NOT PROVIDE
LEGAL SERVICES. DISTRIBUTION OF THIS DOCUMENT DOES NOT CREATE AN
ATTORNEY-CLIENT RELATIONSHIP. CREATIVE COMMONS PROVIDES THIS
INFORMATION ON AN "AS-IS" BASIS. CREATIVE COMMONS MAKES NO WARRANTIES
REGARDING THE USE OF THIS DOCUMENT OR THE INFORMATION OR WORKS
PROVIDED HEREUNDER, AND DISCLAIMS LIABILITY FOR DAMAGES RESULTING FROM
THE USE OF THIS DOCUMENT OR THE INFORMATION OR WORKS PROVIDED
HEREUNDER.
Statement of Purpose
The laws of most jurisdictions throughout the world automatically confer
exclusive Copyright and Related Rights (defined below) upon the creator
and subsequent owner(s) (each and all, an "owner") of an original work of
authorship and/or a database (each, a "Work").
Certain owners wish to permanently relinquish those rights to a Work for
the purpose of contributing to a commons of creative, cultural and
scientific works ("Commons") that the public can reliably and without fear
of later claims of infringement build upon, modify, incorporate in other
works, reuse and redistribute as freely as possible in any form whatsoever
and for any purposes, including without limitation commercial purposes.
These owners may contribute to the Commons to promote the ideal of a free
culture and the further production of creative, cultural and scientific
works, or to gain reputation or greater distribution for their Work in
part through the use and efforts of others.
For these and/or other purposes and motivations, and without any
expectation of additional consideration or compensation, the person
associating CC0 with a Work (the "Affirmer"), to the extent that he or she
is an owner of Copyright and Related Rights in the Work, voluntarily
elects to apply CC0 to the Work and publicly distribute the Work under its
terms, with knowledge of his or her Copyright and Related Rights in the
Work and the meaning and intended legal effect of CC0 on those rights.
1. Copyright and Related Rights. A Work made available under CC0 may be
protected by copyright and related or neighboring rights ("Copyright and
Related Rights"). Copyright and Related Rights include, but are not
limited to, the following:
i. the right to reproduce, adapt, distribute, perform, display,
communicate, and translate a Work;
ii. moral rights retained by the original author(s) and/or performer(s);
iii. publicity and privacy rights pertaining to a person's image or
likeness depicted in a Work;
iv. rights protecting against unfair competition in regards to a Work,
subject to the limitations in paragraph 4(a), below;
v. rights protecting the extraction, dissemination, use and reuse of data
in a Work;
vi. database rights (such as those arising under Directive 96/9/EC of the
European Parliament and of the Council of 11 March 1996 on the legal
protection of databases, and under any national implementation
thereof, including any amended or successor version of such
directive); and
vii. other similar, equivalent or corresponding rights throughout the
world based on applicable law or treaty, and any national
implementations thereof.
2. Waiver. To the greatest extent permitted by, but not in contravention
of, applicable law, Affirmer hereby overtly, fully, permanently,
irrevocably and unconditionally waives, abandons, and surrenders all of
Affirmer's Copyright and Related Rights and associated claims and causes
of action, whether now known or unknown (including existing as well as
future claims and causes of action), in the Work (i) in all territories
worldwide, (ii) for the maximum duration provided by applicable law or
treaty (including future time extensions), (iii) in any current or future
medium and for any number of copies, and (iv) for any purpose whatsoever,
including without limitation commercial, advertising or promotional
purposes (the "Waiver"). Affirmer makes the Waiver for the benefit of each
member of the public at large and to the detriment of Affirmer's heirs and
successors, fully intending that such Waiver shall not be subject to
revocation, rescission, cancellation, termination, or any other legal or
equitable action to disrupt the quiet enjoyment of the Work by the public
as contemplated by Affirmer's express Statement of Purpose.
3. Public License Fallback. Should any part of the Waiver for any reason
be judged legally invalid or ineffective under applicable law, then the
Waiver shall be preserved to the maximum extent permitted taking into
account Affirmer's express Statement of Purpose. In addition, to the
extent the Waiver is so judged Affirmer hereby grants to each affected
person a royalty-free, non transferable, non sublicensable, non exclusive,
irrevocable and unconditional license to exercise Affirmer's Copyright and
Related Rights in the Work (i) in all territories worldwide, (ii) for the
maximum duration provided by applicable law or treaty (including future
time extensions), (iii) in any current or future medium and for any number
of copies, and (iv) for any purpose whatsoever, including without
limitation commercial, advertising or promotional purposes (the
"License"). The License shall be deemed effective as of the date CC0 was
applied by Affirmer to the Work. Should any part of the License for any
reason be judged legally invalid or ineffective under applicable law, such
partial invalidity or ineffectiveness shall not invalidate the remainder
of the License, and in such case Affirmer hereby affirms that he or she
will not (i) exercise any of his or her remaining Copyright and Related
Rights in the Work or (ii) assert any associated claims and causes of
action with respect to the Work, in either case contrary to Affirmer's
express Statement of Purpose.
4. Limitations and Disclaimers.
a. No trademark or patent rights held by Affirmer are waived, abandoned,
surrendered, licensed or otherwise affected by this document.
b. Affirmer offers the Work as-is and makes no representations or
warranties of any kind concerning the Work, express, implied,
statutory or otherwise, including without limitation warranties of
title, merchantability, fitness for a particular purpose, non
infringement, or the absence of latent or other defects, accuracy, or
the present or absence of errors, whether or not discoverable, all to
the greatest extent permissible under applicable law.
c. Affirmer disclaims responsibility for clearing rights of other persons
that may apply to the Work or any use thereof, including without
limitation any person's Copyright and Related Rights in the Work.
Further, Affirmer disclaims responsibility for obtaining any necessary
consents, permissions or other rights required for any use of the
Work.
d. Affirmer understands and acknowledges that Creative Commons is not a
party to this document and has no duty or obligation with respect to
this CC0 or use of the Work.

21
static/licenses/katex.txt Normal file
View file

@ -0,0 +1,21 @@
The MIT License (MIT)
Copyright (c) 2013-2020 Khan Academy and other contributors
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

View file

@ -0,0 +1,93 @@
Copyright 2020 The Newsreader Project Authors (http://github.com/productiontype/Newsreader) Newsreader-Italic[opsz,wght].ttf: Copyright 2020 The Newsreader Project Authors (http://github.com/productiontype/Newsreader)
This Font Software is licensed under the SIL Open Font License, Version 1.1.
This license is copied below, and is also available with a FAQ at:
http://scripts.sil.org/OFL
-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font creation
efforts of academic and linguistic communities, and to provide a free and
open framework in which fonts may be shared and improved in partnership
with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply
to any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software components as
distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to, deleting,
or substituting -- in part or in whole -- any of the components of the
Original Version, by changing formats or by porting the Font Software to a
new environment.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
PERMISSION & CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed, modify,
redistribute, and sell modified and unmodified copies of the Font
Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components,
in Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the corresponding
Copyright Holder. This restriction only applies to the primary font name as
presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created
using the Font Software.
TERMINATION
This license becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.

235
static/licenses/npm.txt Normal file
View file

@ -0,0 +1,235 @@
The npm application
Copyright (c) npm, Inc. and Contributors
Licensed on the terms of The Artistic License 2.0
Node package dependencies of the npm application
Copyright (c) their respective copyright owners
Licensed on their respective license terms
The npm public registry at https://registry.npmjs.org
and the npm website at https://www.npmjs.com
Operated by npm, Inc.
Use governed by terms published on https://www.npmjs.com
"Node.js"
Trademark Joyent, Inc., https://joyent.com
Neither npm nor npm, Inc. are affiliated with Joyent, Inc.
The Node.js application
Project of Node Foundation, https://nodejs.org
The npm Logo
Copyright (c) Mathias Pettersson and Brian Hammond
"Gubblebum Blocky" typeface
Copyright (c) Tjarda Koster, https://jelloween.deviantart.com
Used with permission
--------
The Artistic License 2.0
Copyright (c) 2000-2006, The Perl Foundation.
Everyone is permitted to copy and distribute verbatim copies
of this license document, but changing it is not allowed.
Preamble
This license establishes the terms under which a given free software
Package may be copied, modified, distributed, and/or redistributed.
The intent is that the Copyright Holder maintains some artistic
control over the development of that Package while still keeping the
Package available as open source and free software.
You are always permitted to make arrangements wholly outside of this
license directly with the Copyright Holder of a given Package. If the
terms of this license do not permit the full use that you propose to
make of the Package, you should contact the Copyright Holder and seek
a different licensing arrangement.
Definitions
"Copyright Holder" means the individual(s) or organization(s)
named in the copyright notice for the entire Package.
"Contributor" means any party that has contributed code or other
material to the Package, in accordance with the Copyright Holder's
procedures.
"You" and "your" means any person who would like to copy,
distribute, or modify the Package.
"Package" means the collection of files distributed by the
Copyright Holder, and derivatives of that collection and/or of
those files. A given Package may consist of either the Standard
Version, or a Modified Version.
"Distribute" means providing a copy of the Package or making it
accessible to anyone else, or in the case of a company or
organization, to others outside of your company or organization.
"Distributor Fee" means any fee that you charge for Distributing
this Package or providing support for this Package to another
party. It does not mean licensing fees.
"Standard Version" refers to the Package if it has not been
modified, or has been modified only in ways explicitly requested
by the Copyright Holder.
"Modified Version" means the Package, if it has been changed, and
such changes were not explicitly requested by the Copyright
Holder.
"Original License" means this Artistic License as Distributed with
the Standard Version of the Package, in its current version or as
it may be modified by The Perl Foundation in the future.
"Source" form means the source code, documentation source, and
configuration files for the Package.
"Compiled" form means the compiled bytecode, object code, binary,
or any other form resulting from mechanical transformation or
translation of the Source form.
Permission for Use and Modification Without Distribution
(1) You are permitted to use the Standard Version and create and use
Modified Versions for any purpose without restriction, provided that
you do not Distribute the Modified Version.
Permissions for Redistribution of the Standard Version
(2) You may Distribute verbatim copies of the Source form of the
Standard Version of this Package in any medium without restriction,
either gratis or for a Distributor Fee, provided that you duplicate
all of the original copyright notices and associated disclaimers. At
your discretion, such verbatim copies may or may not include a
Compiled form of the Package.
(3) You may apply any bug fixes, portability changes, and other
modifications made available from the Copyright Holder. The resulting
Package will still be considered the Standard Version, and as such
will be subject to the Original License.
Distribution of Modified Versions of the Package as Source
(4) You may Distribute your Modified Version as Source (either gratis
or for a Distributor Fee, and with or without a Compiled form of the
Modified Version) provided that you clearly document how it differs
from the Standard Version, including, but not limited to, documenting
any non-standard features, executables, or modules, and provided that
you do at least ONE of the following:
(a) make the Modified Version available to the Copyright Holder
of the Standard Version, under the Original License, so that the
Copyright Holder may include your modifications in the Standard
Version.
(b) ensure that installation of your Modified Version does not
prevent the user installing or running the Standard Version. In
addition, the Modified Version must bear a name that is different
from the name of the Standard Version.
(c) allow anyone who receives a copy of the Modified Version to
make the Source form of the Modified Version available to others
under
(i) the Original License or
(ii) a license that permits the licensee to freely copy,
modify and redistribute the Modified Version using the same
licensing terms that apply to the copy that the licensee
received, and requires that the Source form of the Modified
Version, and of any works derived from it, be made freely
available in that license fees are prohibited but Distributor
Fees are allowed.
Distribution of Compiled Forms of the Standard Version
or Modified Versions without the Source
(5) You may Distribute Compiled forms of the Standard Version without
the Source, provided that you include complete instructions on how to
get the Source of the Standard Version. Such instructions must be
valid at the time of your distribution. If these instructions, at any
time while you are carrying out such distribution, become invalid, you
must provide new instructions on demand or cease further distribution.
If you provide valid instructions or cease distribution within thirty
days after you become aware that the instructions are invalid, then
you do not forfeit any of your rights under this license.
(6) You may Distribute a Modified Version in Compiled form without
the Source, provided that you comply with Section 4 with respect to
the Source of the Modified Version.
Aggregating or Linking the Package
(7) You may aggregate the Package (either the Standard Version or
Modified Version) with other packages and Distribute the resulting
aggregation provided that you do not charge a licensing fee for the
Package. Distributor Fees are permitted, and licensing fees for other
components in the aggregation are permitted. The terms of this license
apply to the use and Distribution of the Standard or Modified Versions
as included in the aggregation.
(8) You are permitted to link Modified and Standard Versions with
other works, to embed the Package in a larger work of your own, or to
build stand-alone binary or bytecode versions of applications that
include the Package, and Distribute the result without restriction,
provided the result does not expose a direct interface to the Package.
Items That are Not Considered Part of a Modified Version
(9) Works (including, but not limited to, modules and scripts) that
merely extend or make use of the Package, do not, by themselves, cause
the Package to be a Modified Version. In addition, such works are not
considered parts of the Package itself, and are not subject to the
terms of this license.
General Provisions
(10) Any use, modification, and distribution of the Standard or
Modified Versions is governed by this Artistic License. By using,
modifying or distributing the Package, you accept this license. Do not
use, modify, or distribute the Package, if you do not accept this
license.
(11) If your Modified Version has been derived from a Modified
Version made by someone other than you, you are nevertheless required
to ensure that your Modified Version complies with the requirements of
this license.
(12) This license does not grant you the right to use any trademark,
service mark, tradename, or logo of the Copyright Holder.
(13) This license includes the non-exclusive, worldwide,
free-of-charge patent license to make, have made, use, offer to sell,
sell, import and otherwise transfer the Package with respect to any
patent claims licensable by the Copyright Holder that are necessarily
infringed by the Package. If you institute patent litigation
(including a cross-claim or counterclaim) against any party alleging
that the Package constitutes direct or contributory patent
infringement, then this Artistic License to you shall terminate on the
date that such litigation is filed.
(14) Disclaimer of Warranty:
THE PACKAGE IS PROVIDED BY THE COPYRIGHT HOLDER AND CONTRIBUTORS "AS
IS' AND WITHOUT ANY EXPRESS OR IMPLIED WARRANTIES. THE IMPLIED
WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE, OR
NON-INFRINGEMENT ARE DISCLAIMED TO THE EXTENT PERMITTED BY YOUR LOCAL
LAW. UNLESS REQUIRED BY LAW, NO COPYRIGHT HOLDER OR CONTRIBUTOR WILL
BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES ARISING IN ANY WAY OUT OF THE USE OF THE PACKAGE, EVEN IF
ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
--------

35
tests/fixtures/math.md vendored Normal file
View file

@ -0,0 +1,35 @@
---
title: 'A "quoted" title'
date: 2026-01-01
format: essay
subtitle: 'A careful look at "math"'
abstract: A quoted abstract with a writer's apostrophe.
---
## Inline and block math
A "quoted" sentence and a writer's apostrophe. Inline \(E = mc^2\) and literal `"code"`.
\[
\begin{aligned}
a^2 + b^2 &= c^2 \\
\int_0^1 x^2\,dx &= \frac{1}{3}
\end{aligned}
\]
$$\begin{pmatrix}1 & 0 \\ 0 & 1\end{pmatrix}$$
{{< equation id="entropy" >}}
S = k_{\mathrm B}\ln\Omega
{{< /equation >}}
See {{< eqref "entropy" "equation (1)" >}}.
```js
const literal = "code";
```
## References
A footnote.[^source]
[^source]: Reference text.

14
tests/fixtures/post.md vendored Normal file
View file

@ -0,0 +1,14 @@
---
title: Post features
date: 2026-01-02
format: post
subtitle: An italic subtitle for a post
---
A post with a footnote.[^source] The same source can be cited again.[^source]
Inline math also works in posts: \(E = mc^2\).
[^source]: A reference with *emphasis* and a [link](https://example.com/).
A second paragraph in the same note.