From 9b78e205eb0ceb3a6655539a530cfccb2c26048f Mon Sep 17 00:00:00 2001 From: Extroonie Date: Fri, 18 Sep 2026 15:30:20 -0700 Subject: [PATCH] feat: initial commit --- .dockerignore | 10 + .env.example | 3 + .gitattributes | 1 + .github/workflows/check.yml | 35 + .gitignore | 11 + .prettierignore | 10 + .prettierrc.json | 18 + Caddyfile | 15 + Dockerfile | 26 + LICENSE | 121 ++++ LICENSING.md | 12 + README.md | 3 + THIRD_PARTY_NOTICES.md | 30 + archetypes/default.md | 8 + assets/css/style.css | 637 ++++++++++++++++++ assets/js/contents.js | 84 +++ assets/js/footnotes.js | 81 +++ assets/js/main.js | 88 +++ assets/js/theme-init.js | 10 + bun.lock | 31 + compose.yaml | 35 + content/_index.md | 42 ++ content/writing/_index.md | 4 + content/writing/essays/_index.md | 5 + content/writing/nodejs-workspaces.md | 45 ++ content/writing/posts/_index.md | 5 + data/contacts.toml | 15 + hugo.toml | 47 ++ layouts/404.html | 6 + layouts/_default/_markup/render-heading.html | 3 + .../_default/_markup/render-passthrough.html | 7 + layouts/_default/baseof.html | 73 ++ layouts/_default/rss.xml | 7 + layouts/index.html | 40 ++ layouts/partials/contacts.html | 19 + layouts/partials/contents.html | 9 + layouts/partials/footer.html | 1 + layouts/partials/header.html | 11 + layouts/partials/icon.html | 15 + layouts/partials/structured-data.html | 7 + layouts/partials/text.html | 1 + layouts/partials/writing.html | 7 + layouts/robots.txt | 3 + layouts/shortcodes/eqref.html | 1 + layouts/shortcodes/equation.html | 14 + layouts/writing/list.html | 18 + layouts/writing/single.html | 44 ++ package.json | 26 + scripts/build.mjs | 16 + scripts/check.mjs | 97 +++ scripts/config.mjs | 18 + scripts/format.mjs | 19 + scripts/preview.mjs | 32 + scripts/test-contents.mjs | 109 +++ scripts/test-deploy.mjs | 190 ++++++ scripts/test-footnotes.mjs | 90 +++ scripts/test-preview.mjs | 70 ++ scripts/test.mjs | 193 ++++++ static/apple-touch-icon.png | Bin 0 -> 987 bytes static/favicon.svg | 1 + static/images/Pale_Blue_Dot.png | Bin 0 -> 538822 bytes static/images/nodejs-workspaces-img.png | Bin 0 -> 12794 bytes static/licenses/cc0.txt | 121 ++++ static/licenses/katex.txt | 21 + static/licenses/newsreader.txt | 93 +++ static/licenses/npm.txt | 235 +++++++ tests/fixtures/math.md | 35 + tests/fixtures/post.md | 14 + 68 files changed, 3098 insertions(+) create mode 100644 .dockerignore create mode 100644 .env.example create mode 100644 .gitattributes create mode 100644 .github/workflows/check.yml create mode 100644 .gitignore create mode 100644 .prettierignore create mode 100644 .prettierrc.json create mode 100644 Caddyfile create mode 100644 Dockerfile create mode 100644 LICENSE create mode 100644 LICENSING.md create mode 100644 README.md create mode 100644 THIRD_PARTY_NOTICES.md create mode 100644 archetypes/default.md create mode 100644 assets/css/style.css create mode 100644 assets/js/contents.js create mode 100644 assets/js/footnotes.js create mode 100644 assets/js/main.js create mode 100644 assets/js/theme-init.js create mode 100644 bun.lock create mode 100644 compose.yaml create mode 100644 content/_index.md create mode 100644 content/writing/_index.md create mode 100644 content/writing/essays/_index.md create mode 100644 content/writing/nodejs-workspaces.md create mode 100644 content/writing/posts/_index.md create mode 100644 data/contacts.toml create mode 100644 hugo.toml create mode 100644 layouts/404.html create mode 100644 layouts/_default/_markup/render-heading.html create mode 100644 layouts/_default/_markup/render-passthrough.html create mode 100644 layouts/_default/baseof.html create mode 100644 layouts/_default/rss.xml create mode 100644 layouts/index.html create mode 100644 layouts/partials/contacts.html create mode 100644 layouts/partials/contents.html create mode 100644 layouts/partials/footer.html create mode 100644 layouts/partials/header.html create mode 100644 layouts/partials/icon.html create mode 100644 layouts/partials/structured-data.html create mode 100644 layouts/partials/text.html create mode 100644 layouts/partials/writing.html create mode 100644 layouts/robots.txt create mode 100644 layouts/shortcodes/eqref.html create mode 100644 layouts/shortcodes/equation.html create mode 100644 layouts/writing/list.html create mode 100644 layouts/writing/single.html create mode 100644 package.json create mode 100644 scripts/build.mjs create mode 100644 scripts/check.mjs create mode 100644 scripts/config.mjs create mode 100644 scripts/format.mjs create mode 100644 scripts/preview.mjs create mode 100644 scripts/test-contents.mjs create mode 100644 scripts/test-deploy.mjs create mode 100644 scripts/test-footnotes.mjs create mode 100644 scripts/test-preview.mjs create mode 100644 scripts/test.mjs create mode 100644 static/apple-touch-icon.png create mode 100644 static/favicon.svg create mode 100644 static/images/Pale_Blue_Dot.png create mode 100644 static/images/nodejs-workspaces-img.png create mode 100644 static/licenses/cc0.txt create mode 100644 static/licenses/katex.txt create mode 100644 static/licenses/newsreader.txt create mode 100644 static/licenses/npm.txt create mode 100644 tests/fixtures/math.md create mode 100644 tests/fixtures/post.md diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..bd3ed3a --- /dev/null +++ b/.dockerignore @@ -0,0 +1,10 @@ +.git +node_modules +dist +public +resources +.hugo_build.lock +.env +.env.* +!.env.example +deploy.sh diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..8b9b591 --- /dev/null +++ b/.env.example @@ -0,0 +1,3 @@ +SITE_PORT=8080 +DEV_PORT=1313 +SITE_BASE_URL= diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..6313b56 --- /dev/null +++ b/.gitattributes @@ -0,0 +1 @@ +* text=auto eol=lf diff --git a/.github/workflows/check.yml b/.github/workflows/check.yml new file mode 100644 index 0000000..e14b26a --- /dev/null +++ b/.github/workflows/check.yml @@ -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 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..1d5a972 --- /dev/null +++ b/.gitignore @@ -0,0 +1,11 @@ +.DS_Store +.idea/ +node_modules/ +dist/ +public/ +resources/_gen/ +.hugo_build.lock +.env +.env.* +!.env.example +/deploy.sh diff --git a/.prettierignore b/.prettierignore new file mode 100644 index 0000000..9048613 --- /dev/null +++ b/.prettierignore @@ -0,0 +1,10 @@ +layouts/**/*.json +layouts/**/*.xml +layouts/**/*.rss + +dist/ +public/ +resources/ + +# The template formatter cannot parse dynamic heading tags such as . +layouts/_default/_markup/render-heading.html diff --git a/.prettierrc.json b/.prettierrc.json new file mode 100644 index 0000000..c191f31 --- /dev/null +++ b/.prettierrc.json @@ -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" + } + } + ] +} diff --git a/Caddyfile b/Caddyfile new file mode 100644 index 0000000..6d1567e --- /dev/null +++ b/Caddyfile @@ -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 + } +} diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..c1315a3 --- /dev/null +++ b/Dockerfile @@ -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 diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..0e259d4 --- /dev/null +++ b/LICENSE @@ -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. diff --git a/LICENSING.md b/LICENSING.md new file mode 100644 index 0000000..39833cd --- /dev/null +++ b/LICENSING.md @@ -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). diff --git a/README.md b/README.md new file mode 100644 index 0000000..9184113 --- /dev/null +++ b/README.md @@ -0,0 +1,3 @@ +# extroonie.com + +My personal website, built with [Hugo](https://gohugo.io/). diff --git a/THIRD_PARTY_NOTICES.md b/THIRD_PARTY_NOTICES.md new file mode 100644 index 0000000..ae914a9 --- /dev/null +++ b/THIRD_PARTY_NOTICES.md @@ -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). diff --git a/archetypes/default.md b/archetypes/default.md new file mode 100644 index 0000000..08d90f5 --- /dev/null +++ b/archetypes/default.md @@ -0,0 +1,8 @@ +--- +title: '{{ replace .File.ContentBaseName "-" " " | title }}' +date: '{{ .Date }}' +draft: true +format: post +description: '' +subtitle: '' +--- diff --git a/assets/css/style.css b/assets/css/style.css new file mode 100644 index 0000000..8b7f3af --- /dev/null +++ b/assets/css/style.css @@ -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; + } +} diff --git a/assets/js/contents.js b/assets/js/contents.js new file mode 100644 index 0000000..bf5a4a0 --- /dev/null +++ b/assets/js/contents.js @@ -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(); +})(); diff --git a/assets/js/footnotes.js b/assets/js/footnotes.js new file mode 100644 index 0000000..30dc509 --- /dev/null +++ b/assets/js/footnotes.js @@ -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 }, + ); +})(); diff --git a/assets/js/main.js b/assets/js/main.js new file mode 100644 index 0000000..8c645d4 --- /dev/null +++ b/assets/js/main.js @@ -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); + }); + }); +})(); diff --git a/assets/js/theme-init.js b/assets/js/theme-init.js new file mode 100644 index 0000000..06cfd60 --- /dev/null +++ b/assets/js/theme-init.js @@ -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; +})(); diff --git a/bun.lock b/bun.lock new file mode 100644 index 0000000..c9f093c --- /dev/null +++ b/bun.lock @@ -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=="], + } +} diff --git a/compose.yaml b/compose.yaml new file mode 100644 index 0000000..5ce4d07 --- /dev/null +++ b/compose.yaml @@ -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: diff --git a/content/_index.md b/content/_index.md new file mode 100644 index 0000000..ae9e6ce --- /dev/null +++ b/content/_index.md @@ -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. diff --git a/content/writing/_index.md b/content/writing/_index.md new file mode 100644 index 0000000..56457e5 --- /dev/null +++ b/content/writing/_index.md @@ -0,0 +1,4 @@ +--- +title: Writing +description: "On computers, science, philosophy, and whatever else gets me thinking." +--- diff --git a/content/writing/essays/_index.md b/content/writing/essays/_index.md new file mode 100644 index 0000000..1a4aa24 --- /dev/null +++ b/content/writing/essays/_index.md @@ -0,0 +1,5 @@ +--- +title: Essays +description: "Longer arguments and things worth taking a closer look at." +filterKind: essay +--- diff --git a/content/writing/nodejs-workspaces.md b/content/writing/nodejs-workspaces.md new file mode 100644 index 0000000..2ba92f1 --- /dev/null +++ b/content/writing/nodejs-workspaces.md @@ -0,0 +1,45 @@ +--- +title: "Why I don't like Node.js workspaces" +date: 2024-01-02 +format: post +description: "Monorepos are cool, but the way they're implemented using workspaces... isn't." +--- + +I dislike the workspaces feature of package managers in Node.js, and I will explain why in this post. + +## What are workspaces? + +If you don't know what workspaces are, here's a brief description from [npm Docs](https://docs.npmjs.com/cli/v7/using-npm/workspaces): + +> Workspaces is a generic term that refers to the set of features in the npm cli that provides support to managing multiple packages from your local files system from within a singular top-level, root package. +> +> This set of features makes up for a much more streamlined workflow handling linked packages from the local file system. Automating the linking process as part of `npm install` and avoiding manually having to use `npm link` in order to add references to packages that should be symlinked into the current `node_modules` folder. +> +> We also refer to these packages being auto-symlinked during `npm install` as a single workspace, meaning it's a nested package within the current local file system that is explicitly defined in the [`package.json`](https://docs.npmjs.com/cli/v7/configuring-npm/package-json#workspaces) `workspaces` configuration. + +It exists in most of the package managers, including [pnpm](https://pnpm.io/workspaces) and [Yarn](https://yarnpkg.com/features/workspaces). They can also be integrated with build tools such as [Turborepo](https://turbo.build/repo) and [Nx](https://nx.dev/). + +## The problem + +In a monorepo setup, workspaces allow you to install dependencies separately for each package or application in the repo. This is different from a traditional setup where all dependencies would be installed at the root of the repo. + +A key distinction exists between regular dependencies and developer dependencies like linting/testing tools. Regular dependencies can be installed at the package root as usual with workspaces. However, developer dependencies typically need to be hoisted and installed only at the workspace root. + +This can be problematic because it means each workspace must install its own copies of all the various dev tools needed, leading to duplication. There is no central place to consolidate tooling. + +## The solution + +> I've grown increasingly convinced over the past year that the TypeScript monorepos should move towards installing everything at the root of the monorepo. This is also what Nx recommends, at least for brand-new monorepos: +> +> ![A recommendation to install dependencies at the root of a monorepo](/images/nodejs-workspaces-img.png) +> +> This strategy has a lot of elegance to it, as it makes working in the repo ultra-simple and rips out a ton of the crappy complexity that we had before: +> +> 1. You only have one `node_modules` directory, and it's at the root of the monorepo. The mental model here is super clean. +> 2. You don't use the "workspaces" feature of the package manager whatsoever. It's very annoying to have to remember to put in workspace-related command-line flags whenever e.g. adding a new dep. This brings parity between "normal" single-project repos and monorepos. +> 3. You don't have to worry about making somewhat-arbitrary distinctions between "ESLint-like" deps that should apply to the entire mono-repo, and "library-like" deps that should only apply to specific packages. Everything just goes into the root as a normal dep or a dev dep (in the same way that a normal/single repo does it). +> 4. Updating dependencies becomes ultra simple - you don't need some over-engineered monorepo tool to do it! You can just run conventional tools like [`npm-check-updates`](https://github.com/raineorshine/npm-check-updates), and then after updating a single "package.json" file, you can simply run `npm/yarn/pnpm install`. +> - And checking for breaking changes after a new dep update also becomes easy. After updating, we can trigger a commit, and TypeScript will take over and let you know in CI in any of the new API changes broke one of your existing packages. Then we can revert the commit if needed, or continue to go on and make another commit to fix to the individual broken packages (or coordinate with other teams, if necessary). +> - In some monorepos, some packages are published to npm, so they need their own individual "package.json" files. Nx offers the option to automatically create package.json files when publishing, but sometimes you will want more control over them, and will choose to manually manage them yourself. (Does Turborepo have an analogous feature?) If you do manually manage "package.json" files, then you need an additional tool to propagate version changes downward from the root "package.json". With that said, even in this situation, I think it is preferable to use simple tools like [`syncpack`](https://github.com/JamieMason/syncpack) to do this, rather than complicated tools like e.g. `lerna`. (`syncpack` is essentially just a glorified copy-paster, so even in this situation, we are still maintaining the simple mental model of how dependencies work.) + +[Source](https://github.com/vercel/turbo/issues/2060#issuecomment-1416135197) diff --git a/content/writing/posts/_index.md b/content/writing/posts/_index.md new file mode 100644 index 0000000..15543f0 --- /dev/null +++ b/content/writing/posts/_index.md @@ -0,0 +1,5 @@ +--- +title: Posts +description: "Observations, explanations, and occasional notes." +filterKind: post +--- diff --git a/data/contacts.toml b/data/contacts.toml new file mode 100644 index 0000000..5dd0c7b --- /dev/null +++ b/data/contacts.toml @@ -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' diff --git a/hugo.toml b/hugo.toml new file mode 100644 index 0000000..3fcb798 --- /dev/null +++ b/hugo.toml @@ -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'] diff --git a/layouts/404.html b/layouts/404.html new file mode 100644 index 0000000..3271f68 --- /dev/null +++ b/layouts/404.html @@ -0,0 +1,6 @@ +{{ define "main" }} +
+

Page not found

+

This page isn’t here. Head back to the homepage.

+
+{{ end }} diff --git a/layouts/_default/_markup/render-heading.html b/layouts/_default/_markup/render-heading.html new file mode 100644 index 0000000..5bd58f0 --- /dev/null +++ b/layouts/_default/_markup/render-heading.html @@ -0,0 +1,3 @@ + + {{ .Text | safeHTML }} + diff --git a/layouts/_default/_markup/render-passthrough.html b/layouts/_default/_markup/render-passthrough.html new file mode 100644 index 0000000..ec2408a --- /dev/null +++ b/layouts/_default/_markup/render-passthrough.html @@ -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" -}} +
{{ $math }}
+{{- else -}} + {{ $math }} +{{- end -}} diff --git a/layouts/_default/baseof.html b/layouts/_default/baseof.html new file mode 100644 index 0000000..c799d10 --- /dev/null +++ b/layouts/_default/baseof.html @@ -0,0 +1,73 @@ +{{- $content := .Content -}} +{{- $contents := partial "contents.html" . -}} +{{- .Store.Set "contents" $contents -}} +{{- $hasContents := and .IsPage (eq .Section "writing") (ge (len (findRE ` + + + + + + {{ $theme := resources.Get "js/theme-init.js" | minify }} + {{ $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 }} + {{ $documentTitle }} + + + {{ if site.Params.preview }}{{ end }} + + + + + + {{ $type := cond (and .IsPage (eq .Section "writing")) "article" "website" }} + + + + + + + {{ $css := resources.Get "css/style.css" | minify | fingerprint }} + + {{ range .AlternativeOutputFormats }} + {{ if eq .Name "RSS" }} + + {{ end }} + {{ end }} + {{ if or (.Store.Get "hasMath") .Params.math }} + + {{ end }} + {{ partial "structured-data.html" . }} + + + {{ partial "header.html" . }} +
{{ block "main" . }}{{ end }}
+ {{ partial "footer.html" . }} + {{ $js := resources.Get "js/main.js" | minify | fingerprint }} + + {{ if $hasContents }} + {{ $navigation := resources.Get "js/contents.js" | minify | fingerprint }} + + {{ end }} + {{ if strings.Contains $content "class=\"footnote-ref\"" }} + {{ $footnotes := resources.Get "js/footnotes.js" | minify | fingerprint }} + + {{ end }} + + diff --git a/layouts/_default/rss.xml b/layouts/_default/rss.xml new file mode 100644 index 0000000..a2ae6b9 --- /dev/null +++ b/layouts/_default/rss.xml @@ -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 "" | safeHTML }} +{{ site.Title }} — {{ with .Params.filterKind }}{{ if eq . "essay" }}Essays{{ else }}Posts{{ end }}{{ else }}Writing{{ end }}{{ site.BaseURL }}{{ partial "text.html" (.Description | default site.Params.description) | plainify | htmlUnescape }}{{ site.Language.Locale }}{{ with .OutputFormats.Get "RSS" }}{{ end }} +{{ range first 30 $pages.ByDate.Reverse }}{{ partial "text.html" .Title | plainify | htmlUnescape }}{{ .Permalink }}{{ .Permalink }}{{ .Date.Format "Mon, 02 Jan 2006 15:04:05 -0700" }}{{ partial "text.html" (.Description | default (.Summary | plainify)) | plainify | htmlUnescape }}{{ end }} + diff --git a/layouts/index.html b/layouts/index.html new file mode 100644 index 0000000..9c94405 --- /dev/null +++ b/layouts/index.html @@ -0,0 +1,40 @@ +{{ define "main" }} +
+{{ end }} diff --git a/layouts/partials/contacts.html b/layouts/partials/contacts.html new file mode 100644 index 0000000..ad9dc97 --- /dev/null +++ b/layouts/partials/contacts.html @@ -0,0 +1,19 @@ + diff --git a/layouts/partials/contents.html b/layouts/partials/contents.html new file mode 100644 index 0000000..0f515aa --- /dev/null +++ b/layouts/partials/contents.html @@ -0,0 +1,9 @@ +{{- $contents := .TableOfContents -}} +{{- if and .IsPage (eq .Section "writing") .Params.abstract -}} + {{- if strings.Contains $contents "