mirror of
https://github.com/Extroonie/extroonie.com.git
synced 2026-10-12 17:48:06 -07:00
feat: initial commit
This commit is contained in:
commit
9b78e205eb
68 changed files with 3098 additions and 0 deletions
10
.dockerignore
Normal file
10
.dockerignore
Normal 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
3
.env.example
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
SITE_PORT=8080
|
||||||
|
DEV_PORT=1313
|
||||||
|
SITE_BASE_URL=
|
||||||
1
.gitattributes
vendored
Normal file
1
.gitattributes
vendored
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
* text=auto eol=lf
|
||||||
35
.github/workflows/check.yml
vendored
Normal file
35
.github/workflows/check.yml
vendored
Normal 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
11
.gitignore
vendored
Normal 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
10
.prettierignore
Normal 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
18
.prettierrc.json
Normal 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
15
Caddyfile
Normal 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
26
Dockerfile
Normal 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
121
LICENSE
Normal 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
12
LICENSING.md
Normal 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
3
README.md
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
# extroonie.com
|
||||||
|
|
||||||
|
My personal website, built with [Hugo](https://gohugo.io/).
|
||||||
30
THIRD_PARTY_NOTICES.md
Normal file
30
THIRD_PARTY_NOTICES.md
Normal 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
8
archetypes/default.md
Normal 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
637
assets/css/style.css
Normal 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
84
assets/js/contents.js
Normal 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
81
assets/js/footnotes.js
Normal 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
88
assets/js/main.js
Normal 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
10
assets/js/theme-init.js
Normal 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
31
bun.lock
Normal 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
35
compose.yaml
Normal 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
42
content/_index.md
Normal 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.
|
||||||
4
content/writing/_index.md
Normal file
4
content/writing/_index.md
Normal file
|
|
@ -0,0 +1,4 @@
|
||||||
|
---
|
||||||
|
title: Writing
|
||||||
|
description: "On computers, science, philosophy, and whatever else gets me thinking."
|
||||||
|
---
|
||||||
5
content/writing/essays/_index.md
Normal file
5
content/writing/essays/_index.md
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
---
|
||||||
|
title: Essays
|
||||||
|
description: "Longer arguments and things worth taking a closer look at."
|
||||||
|
filterKind: essay
|
||||||
|
---
|
||||||
45
content/writing/nodejs-workspaces.md
Normal file
45
content/writing/nodejs-workspaces.md
Normal 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:
|
||||||
|
>
|
||||||
|
> 
|
||||||
|
>
|
||||||
|
> 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)
|
||||||
5
content/writing/posts/_index.md
Normal file
5
content/writing/posts/_index.md
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
---
|
||||||
|
title: Posts
|
||||||
|
description: "Observations, explanations, and occasional notes."
|
||||||
|
filterKind: post
|
||||||
|
---
|
||||||
15
data/contacts.toml
Normal file
15
data/contacts.toml
Normal 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
47
hugo.toml
Normal 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
6
layouts/404.html
Normal 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 }}
|
||||||
3
layouts/_default/_markup/render-heading.html
Normal file
3
layouts/_default/_markup/render-heading.html
Normal 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 }}>
|
||||||
7
layouts/_default/_markup/render-passthrough.html
Normal file
7
layouts/_default/_markup/render-passthrough.html
Normal 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 -}}
|
||||||
73
layouts/_default/baseof.html
Normal file
73
layouts/_default/baseof.html
Normal 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
7
layouts/_default/rss.xml
Normal 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
40
layouts/index.html
Normal 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 }}
|
||||||
19
layouts/partials/contacts.html
Normal file
19
layouts/partials/contacts.html
Normal 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>
|
||||||
9
layouts/partials/contents.html
Normal file
9
layouts/partials/contents.html
Normal 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) -}}
|
||||||
1
layouts/partials/footer.html
Normal file
1
layouts/partials/footer.html
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
<div class="notice" role="status" aria-live="polite" aria-atomic="true"></div>
|
||||||
11
layouts/partials/header.html
Normal file
11
layouts/partials/header.html
Normal 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>
|
||||||
15
layouts/partials/icon.html
Normal file
15
layouts/partials/icon.html
Normal 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 |
7
layouts/partials/structured-data.html
Normal file
7
layouts/partials/structured-data.html
Normal 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>
|
||||||
1
layouts/partials/text.html
Normal file
1
layouts/partials/text.html
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
{{- return (. | markdownify) -}}
|
||||||
7
layouts/partials/writing.html
Normal file
7
layouts/partials/writing.html
Normal 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
3
layouts/robots.txt
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
User-agent: *
|
||||||
|
{{ if site.Params.preview }}Disallow: /{{ else }}Allow: /
|
||||||
|
Sitemap: {{ "sitemap.xml" | absURL }}{{ end }}
|
||||||
1
layouts/shortcodes/eqref.html
Normal file
1
layouts/shortcodes/eqref.html
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
{{- $id := .Get 0 -}}{{- $label := .Get 1 | default "equation" -}}<a href="#eq-{{ $id | urlize }}">{{ $label }}</a>
|
||||||
14
layouts/shortcodes/equation.html
Normal file
14
layouts/shortcodes/equation.html
Normal 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
18
layouts/writing/list.html
Normal 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>
|
||||||
44
layouts/writing/single.html
Normal file
44
layouts/writing/single.html
Normal 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
26
package.json
Normal 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
16
scripts/build.mjs
Normal 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
97
scripts/check.mjs
Normal 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
18
scripts/config.mjs
Normal 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
19
scripts/format.mjs
Normal 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
32
scripts/preview.mjs
Normal 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
109
scripts/test-contents.mjs
Normal 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
190
scripts/test-deploy.mjs
Normal 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 });
|
||||||
|
}
|
||||||
90
scripts/test-footnotes.mjs
Normal file
90
scripts/test-footnotes.mjs
Normal 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
70
scripts/test-preview.mjs
Normal 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
193
scripts/test.mjs
Normal 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’s') || page.includes('writer's'),
|
||||||
|
'Smart apostrophe missing',
|
||||||
|
);
|
||||||
|
assert(
|
||||||
|
page.includes('<code>"code"</code>') ||
|
||||||
|
page.includes('<code>"code"</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
BIN
static/apple-touch-icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 987 B |
1
static/favicon.svg
Normal file
1
static/favicon.svg
Normal 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 |
BIN
static/images/Pale_Blue_Dot.png
Normal file
BIN
static/images/Pale_Blue_Dot.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 526 KiB |
BIN
static/images/nodejs-workspaces-img.png
Normal file
BIN
static/images/nodejs-workspaces-img.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 12 KiB |
121
static/licenses/cc0.txt
Normal file
121
static/licenses/cc0.txt
Normal 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
21
static/licenses/katex.txt
Normal 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.
|
||||||
93
static/licenses/newsreader.txt
Normal file
93
static/licenses/newsreader.txt
Normal 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
235
static/licenses/npm.txt
Normal 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
35
tests/fixtures/math.md
vendored
Normal 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
14
tests/fixtures/post.md
vendored
Normal 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.
|
||||||
Loading…
Add table
Reference in a new issue