commit 68e2c309286701c5ee23b386b6b96d28f2af0528 Author: Extroonie Date: Mon Sep 21 12:31:51 2026 -0700 feat: initial commit 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..e9a6266 --- /dev/null +++ b/.gitignore @@ -0,0 +1,10 @@ +.DS_Store +.idea/ +node_modules/ +dist/ +public/ +resources/_gen/ +.hugo_build.lock +.env +.env.* +!.env.example 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..fe95486 --- /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 tzdata \ + && 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..493630a --- /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. + +The [Pale Blue Dot](assets/images/Pale_Blue_Dot.png) photograph was taken by +NASA’s Voyager 1 spacecraft. Cropped and resized versions are used for the site’s +browser icons and link previews. +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..dfc7f59 --- /dev/null +++ b/archetypes/default.md @@ -0,0 +1,9 @@ +--- +title: '{{ replace .File.ContentBaseName "-" " " | title }}' +date: '{{ .Date }}' +draft: true +format: post +tags: [] +description: '' +subtitle: '' +--- diff --git a/assets/css/style.css b/assets/css/style.css new file mode 100644 index 0000000..7d38393 --- /dev/null +++ b/assets/css/style.css @@ -0,0 +1,690 @@ +@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; +} +.biography { + padding-top: 1.75rem; +} +.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; +} +.archive-toolbar { + display: flex; + flex-wrap: wrap; + align-items: baseline; + justify-content: space-between; + gap: 0.5rem 1.5rem; + margin-block: 1.5rem 2rem; +} +.writing-filters { + display: flex; + flex-wrap: wrap; + gap: 0.5rem 1.25rem; + font-size: 1rem; +} +.writing-filters a { + padding-block: 0.25rem; +} +.writing-filters [aria-current] { + font-weight: 600; + text-decoration: none; +} +.rss-link { + color: var(--muted); + font: + 400 0.75rem/1.5 system-ui, + sans-serif; +} +.writing-list, +.tag-index { + list-style: none; + margin: 0; + padding: 0; +} +.writing-list > li + li { + margin-top: 2rem; +} +.writing-entry h2 { + margin: 0; +} +.writing-entry p { + font-size: 1.125rem; + margin: 0.5rem 0 0; +} +.writing-archive { + overflow-wrap: anywhere; +} +.writing-metadata { + display: flex; + flex-wrap: wrap; + align-items: baseline; + gap: 0.25rem 0.75rem; + margin-top: 0.5rem; + font-size: 1rem; + line-height: 1.5; + color: var(--muted); +} +.writing-metadata time { + padding-block: 0.125rem; +} +.writing-metadata .tags { + margin: 0; +} +.tag-index a { + display: inline-block; + padding-block: 0.25rem; +} +.tags { + display: flex; + flex-wrap: wrap; + gap: 0.25rem 0.75rem; + list-style: none; + margin: 0.5rem 0 0; + padding: 0; + font-size: 1rem; + line-height: 1.5; + color: var(--muted); +} +.tags a { + display: inline-block; + padding-block: 0.125rem; + overflow-wrap: anywhere; +} +.tags li { + min-width: 0; +} +.article-intro { + margin-top: 0.75rem; +} +.article-intro .tags { + margin-bottom: 0.75rem; +} +.all-writing { + display: inline-block; + margin-bottom: 1rem; + font-size: 1rem; + color: var(--muted); +} +.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; + font-style: normal; + font-weight: 400; + letter-spacing: normal; + 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 { + --paper: white; + --ink: black; + --muted: #444; + --surface: #eee; + color-scheme: light; + background: var(--paper); + color: var(--ink); + } + .theme-toggle, + .contact-links, + .contents, + .article-contents, + .contents-reopen, + .all-writing, + .skip-link, + .heading-anchor, + .footnote-preview, + .notice { + display: none; + } + .shell { + width: 100%; + } + 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/images/Pale_Blue_Dot.png b/assets/images/Pale_Blue_Dot.png new file mode 100644 index 0000000..b51d4d7 Binary files /dev/null and b/assets/images/Pale_Blue_Dot.png differ 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..66004d8 --- /dev/null +++ b/assets/js/main.js @@ -0,0 +1,70 @@ +(() => { + 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' && event.key !== null) return; + manual = ['light', 'dark'].includes(event.newValue); + root.dataset.themeChoice = manual ? event.newValue : 'system'; + setTheme(manual ? event.newValue : system.matches ? 'dark' : 'light'); + }); + } + function fragmentTarget(hash = location.hash) { + let id; + try { + id = decodeURIComponent(hash.slice(1)); + } catch { + return; + } + return id ? document.getElementById(id) : null; + } + function focusFragment() { + const target = fragmentTarget(); + if (!target) return; + requestAnimationFrame(() => { + target.scrollIntoView({ block: 'start' }); + target.focus({ preventScroll: true }); + }); + } + window.addEventListener('hashchange', focusFragment); + if (location.hash) focusFragment(); + 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..82d0bd8 --- /dev/null +++ b/content/writing/_index.md @@ -0,0 +1,4 @@ +--- +title: My Writing +description: "Every so often, I put pen to paper (or fingers to keys) and end up with something I’d like to share. Perhaps you’ll find something here that interests you." +--- diff --git a/content/writing/essays/_index.md b/content/writing/essays/_index.md new file mode 100644 index 0000000..8c38593 --- /dev/null +++ b/content/writing/essays/_index.md @@ -0,0 +1,4 @@ +--- +title: Essays +filterKind: essay +--- diff --git a/content/writing/explore-and-exploit.md b/content/writing/explore-and-exploit.md new file mode 100644 index 0000000..128bac5 --- /dev/null +++ b/content/writing/explore-and-exploit.md @@ -0,0 +1,19 @@ +--- +title: Explore and exploit +date: 2026-09-20 +format: post +tags: [philosophy, language] +description: "I liked how the words sounded together. Then I started thinking about what they meant." +--- + +About a year ago, while watching something, I heard the word “disfigure”. My mind immediately went looking for an alliterative companion and landed on “discover”. Following that thread a little further, I came up with another pair: “explore and exploit”. Why I was doing any of this is a mystery to me, and most of the thought process is quite foggy now. But somewhere along the way, I found myself thinking about militaries. + +“Discover and disfigure” sounds awfully close to what militaries do in warfare, doesn’t it? Find a target, learn about it, and use that knowledge to damage or destroy it. There’s much more to their work than that, of course, but so much effort goes into understanding something well enough to ruin it. + +Then there’s “explore and exploit”. Exploration leaves open what we’re going to do with what we find. “Exploit” sounds less promising, but as a software developer, I’m tempted to borrow from cybersecurity here. Exploiting a vulnerability means taking advantage of a weakness in a system. That can certainly be destructive. But malicious software can have weaknesses too. Finding one might let you stop it from working. You’d be exploiting a vulnerability to protect yourself against an attack. + +A military could use what it learns in much the same way. Finding weaknesses in another country’s weapons or tactics could help it work out how to counter an attack. Understanding a weapon’s limitations, for example, might reveal a way to prevent it from reaching its target. With that defensive purpose in mind, “explore and exploit” offers a counterpart to “discover and disfigure”: using what we learn about others’ vulnerabilities to protect ourselves. + +And I think keeping people safe should guide how militaries use the knowledge they gather. I’d love to see more of their ingenuity devoted to finding ways to counter attacks and limit the harm they can cause. Defending yourself sometimes requires fighting back, of course. But I think we all stand to gain more in the long run from learning to cooperate. How to make that cooperation last is a difficult question, and I don’t pretend to have an answer. I do think it’s worth working toward. + +Anyway, I hope you found that little connection as interesting as I did! I certainly wasn’t expecting a search for words that sound nice together to leave me thinking about what militaries should be doing. diff --git a/content/writing/nodejs-workspaces.md b/content/writing/nodejs-workspaces.md new file mode 100644 index 0000000..fa1d36b --- /dev/null +++ b/content/writing/nodejs-workspaces.md @@ -0,0 +1,46 @@ +--- +title: "Why I don't like Node.js workspaces" +date: 2024-01-02 +format: post +tags: [programming, nodejs] +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 (full-size image)](/images/nodejs-workspaces-img.png)](/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..bbcbae9 --- /dev/null +++ b/content/writing/posts/_index.md @@ -0,0 +1,4 @@ +--- +title: Posts +filterKind: post +--- diff --git a/content/writing/why-read-classics.md b/content/writing/why-read-classics.md new file mode 100644 index 0000000..048f03c --- /dev/null +++ b/content/writing/why-read-classics.md @@ -0,0 +1,29 @@ +--- +title: Why read classics? +date: 2026-09-21 +format: post +tags: [literature] +description: "What classics still offer when we have so much else to read." +--- + +Someone once remarked in a conversation that classics are often overrated. I enjoy reading them and have gotten a great deal out of them, so I tried to explain their appeal. I thought I’d share my answer here, too. + +## What makes them worth reading + +My first thought was the distinctive way their authors approached things. Two people can explore the same idea or tell much the same story and produce very different works. An author’s choice of what to emphasize and how to present it can change how we understand a familiar subject. That’s part of the appeal of many classics. Later authors may have built on those approaches and produced something we like even more, but we can still appreciate what made the originals so compelling. + +The stories themselves matter as well. Take Superman, for example. There were heroic figures before him, but he helped establish the superhero as we know it. His early adventures gave readers someone with extraordinary powers who used them to stand up for others. That appealed to people then, and it still does! You might think there are much better superhero stories now, but reading the early comics gives you a chance to understand what first caught readers’ imaginations. You can see what those stories meant to people at the time and find out what they have to offer you. + +But their influence didn’t end with those first readers. So much of modern thought and culture has grown out of earlier works. We encounter their ideas through later authors, often without realizing where they came from or how much they’ve changed along the way. Reading the originals helps us understand those connections. If you’re interested in a genre, its classics can tell you a great deal about how it developed. You may even return to a favorite work and appreciate the significance of something you’d barely noticed before. + +## Style, story, and significance + +Looking over these reasons, I noticed I had a trio. Naturally, I wanted a nice way to put them together. + +That brought to mind Muhammad Ali’s description of what his trainer had helped him develop: “my style, my stamina and my system.” I loved the rhythm of it and wondered whether something similar would work here. + +*Style* already fit what I’d been saying about each author’s distinctive approach. The stories themselves gave me *story*. That left the wider importance and influence of these works. *Significance*! + +**Style, story, and significance.** Beautiful! And the alliteration is certainly a nice bonus. + +I hope that gives you some sense of what classics can offer, and perhaps a reason to try one you’ve been curious about. A work that influenced an author you love or explores a subject that interests you could be a good place to start. You might find more to enjoy in it than you expected. 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/deploy.sh b/deploy.sh new file mode 100755 index 0000000..723c5e5 --- /dev/null +++ b/deploy.sh @@ -0,0 +1,118 @@ +#!/bin/sh +set -eu + +main() { + case "$#:${1:-}" in + 0:) pull=false ;; + 1:--pull) pull=true ;; + *) echo 'Usage: ./deploy.sh [--pull]' >&2; exit 2 ;; + esac + cd "$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)" + command -v git >/dev/null + command -v docker >/dev/null + docker compose version >/dev/null + docker info >/dev/null + + lock_dir="$(git rev-parse --git-path extroonie-deploy.lock)" + if ! mkdir "$lock_dir" 2>/dev/null; then + echo "Another deployment is running. If it was interrupted, remove $lock_dir after checking." >&2 + exit 1 + fi + trap 'rm -f "$lock_dir/previous.yaml" "$lock_dir/candidate.yaml"; rmdir "$lock_dir"' EXIT + trap 'exit 130' INT + trap 'exit 143' TERM + + if "$pull"; then + if [ -n "$(git status --porcelain --untracked-files=normal)" ]; then + echo 'The --pull option needs a clean checkout. Omit --pull to deploy local changes.' >&2 + exit 1 + fi + branch=$(git symbolic-ref --quiet --short HEAD) || { + echo 'Check out a branch to use --pull, or omit --pull to deploy the current files.' >&2 + exit 1 + } + fi + umask 077 + state_dir="$(git rev-parse --git-path extroonie-deploy)" + mkdir -p "$state_dir" + previous_container=$(docker compose ps -q site) + previous_id='' + if [ -n "$previous_container" ]; then + previous_id=$(docker inspect --format '{{.Image}}' "$previous_container") + if [ -f "$state_dir/current.yaml" ] && [ -f "$state_dir/container" ] && + [ "$(cat "$state_dir/container")" = "$previous_container" ]; then + cp "$state_dir/current.yaml" "$lock_dir/previous.yaml" + else + previous_image=$(docker inspect --format '{{.Config.Image}}' "$previous_container") + previous_hash=$(docker inspect --format '{{index .Config.Labels "com.docker.compose.config-hash"}}' "$previous_container") + config_hash=$(SITE_IMAGE=$previous_image docker compose config --hash site) + if [ "$config_hash" != "site $previous_hash" ]; then + echo 'No saved configuration matches the running container, and the current Compose settings differ.' >&2 + echo 'Restore its Compose settings before deploying so a failed replacement can be rolled back safely.' >&2 + exit 1 + fi + SITE_IMAGE=$previous_id docker compose config site > "$lock_dir/previous.yaml" + cp "$lock_dir/previous.yaml" "$state_dir/current.yaml" + printf '%s\n' "$previous_container" > "$state_dir/container" + fi + fi + if "$pull"; then + remote=${DEPLOY_REMOTE:-origin} + echo "Updating $branch from $remote..." + git pull --ff-only "$remote" "$branch" + fi + + stable_image=$(docker compose config --images site) + case "$stable_image" in + ''|*@*|*[[:space:]]*) echo 'The site image must be a single, taggable image name.' >&2; exit 1 ;; + esac + revision=$(git rev-parse --short=12 HEAD 2>/dev/null || printf 'local') + if [ -n "$(git status --porcelain --untracked-files=normal)" ]; then + revision="$revision-dirty" + fi + SITE_IMAGE="extroonie:$revision-$(date -u +%Y%m%d%H%M%S)-$$" + export SITE_IMAGE + docker compose config site > "$lock_dir/candidate.yaml" + echo 'Building and validating the replacement while the existing site keeps running...' + compose_candidate build --pull site + if ! compose_candidate up -d --no-deps --no-build --wait --wait-timeout 60 site; then + echo 'Replacement failed its health check.' >&2 + compose_candidate logs --tail 50 site >&2 || true + if [ -n "$previous_id" ]; then + echo "Restoring the previous image and Compose configuration..." >&2 + docker compose --project-directory "$PWD" -f "$lock_dir/previous.yaml" up -d --no-deps --no-build --wait --wait-timeout 60 site || { + echo "Automatic rollback failed; recovery configuration: $state_dir/current.yaml" >&2 + cp "$lock_dir/previous.yaml" "$state_dir/current.yaml" + exit 1 + } + docker image tag "$previous_id" "$stable_image" + cp "$lock_dir/previous.yaml" "$state_dir/current.yaml" + docker compose --project-directory "$PWD" -f "$state_dir/current.yaml" ps -q site > "$state_dir/container" + echo "Rolled back. Use docker compose --project-directory '$PWD' -f '$state_dir/current.yaml' for the restored configuration until the next successful deployment." >&2 + fi + exit 1 + fi + + current_id=$(docker image inspect --format '{{.Id}}' "$SITE_IMAGE") + docker image tag "$current_id" "$stable_image" + cp "$lock_dir/candidate.yaml" "$state_dir/current.yaml" + compose_candidate ps -q site > "$state_dir/container" + echo 'Removing older site images; keeping the current and previous images...' + docker image ls --filter label=com.extroonie.app=website --format '{{.Repository}}:{{.Tag}} {{.ID}}' --no-trunc | + while read -r image_name image_id; do + [ "$image_id" != "$current_id" ] || continue + [ "$image_id" != "$previous_id" ] || continue + case "$image_name" in + extroonie:*) docker image rm "$image_name" || true ;; + ':') docker image rm "$image_id" || true ;; + esac + done + echo "Deployed $revision. Listening on:" + compose_candidate port site 8080 +} + +compose_candidate() { + docker compose --project-directory "$PWD" -f "$lock_dir/candidate.yaml" "$@" +} + +main "$@" diff --git a/hugo.toml b/hugo.toml new file mode 100644 index 0000000..9bc062d --- /dev/null +++ b/hugo.toml @@ -0,0 +1,51 @@ +baseURL = 'https://extroonie.com/' +locale = 'en-us' +timeZone = 'Asia/Dhaka' +title = 'Ishmaam Khan' +publishDir = 'dist' +enableRobotsTXT = true +disableHugoGeneratorInject = true +summaryLength = 40 +[taxonomies] +tag = 'tags' +[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'] +taxonomy = ['HTML'] +term = ['HTML'] +[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..d7b135a --- /dev/null +++ b/layouts/_default/baseof.html @@ -0,0 +1,71 @@ +{{- $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 "description.html" . }} + {{ if eq .Kind "term" }} + {{ $title = printf "Writing tagged #%s" .Data.Term }} + {{ end }} + {{ $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" }} + + {{ partialCached "site-images.html" . }} + + {{ $css := resources.Get "css/style.css" | minify | fingerprint }} + + {{ with .OutputFormats.Get "RSS" }} + + {{ 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..c9091bc --- /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 }} +{{ partial "feed-title.html" . }}{{ with .OutputFormats.Get "HTML" }}{{ .Permalink }}{{ end }}{{ partial "description.html" (site.GetPage "/writing") }}{{ 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/_default/taxonomy.html b/layouts/_default/taxonomy.html new file mode 100644 index 0000000..bf97e89 --- /dev/null +++ b/layouts/_default/taxonomy.html @@ -0,0 +1,3 @@ +{{ define "main" }} + {{ partial "writing-archive.html" . }} +{{ end }} diff --git a/layouts/_default/term.html b/layouts/_default/term.html new file mode 100644 index 0000000..bf97e89 --- /dev/null +++ b/layouts/_default/term.html @@ -0,0 +1,3 @@ +{{ define "main" }} + {{ partial "writing-archive.html" . }} +{{ end }} diff --git a/layouts/index.html b/layouts/index.html new file mode 100644 index 0000000..f48f7d0 --- /dev/null +++ b/layouts/index.html @@ -0,0 +1,15 @@ +{{ define "main" }} +
+
+

{{ partial "text.html" .Params.introduction }}

+

You might enjoy some of my writing.

+ {{ partial "contacts.html" . }} +
+
+ On this page{{ .Store.Get "contents" }} +
+
{{ .Content }}
+
+
+
+{{ 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..f52b80a --- /dev/null +++ b/layouts/partials/contents.html @@ -0,0 +1,10 @@ +{{- $contents := .TableOfContents -}} +{{- if and .IsPage (eq .Section "writing") .Params.abstract -}} + {{- if strings.Contains $contents "
    " -}} + {{- $contents = replace $contents "
      " `
      • Abstract
      • ` 1 -}} + {{- else -}} + {{- $contents = `` -}} + {{- end -}} +{{- end -}} +{{- $label := cond .IsHome "On this page" "Contents" -}} +{{- return ($contents | replaceRE `