diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 83ea59c..be075c6 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -14,5 +14,9 @@ jobs:
with:
ruby-version: "3.3"
bundler-cache: true
+ - uses: actions/setup-node@v4
+ with:
+ node-version: "22"
+ cache: npm
- run: bin/check_unicode
- run: bin/build
diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml
index d849ce7..a664332 100644
--- a/.github/workflows/deploy.yml
+++ b/.github/workflows/deploy.yml
@@ -26,6 +26,10 @@ jobs:
with:
ruby-version: "3.3"
bundler-cache: true
+ - uses: actions/setup-node@v4
+ with:
+ node-version: "22"
+ cache: npm
- run: bin/build
- uses: actions/configure-pages@v5
- uses: actions/upload-pages-artifact@v3
diff --git a/.gitignore b/.gitignore
index d1d00dd..587667a 100644
--- a/.gitignore
+++ b/.gitignore
@@ -6,6 +6,9 @@ _site/
# Bundler
vendor/
+# Node.js build dependencies
+node_modules/
+
# macOS-specific files
.DS_Store
diff --git a/README.md b/README.md
index be39890..643c434 100644
--- a/README.md
+++ b/README.md
@@ -6,6 +6,7 @@ Personal portfolio and blog built with [Jekyll](https://jekyllrb.com/).
```sh
bundle install
+npm install
```
## Run
@@ -25,6 +26,7 @@ bin/build
```
Builds the site and generates a PDF for each blog article with Chrome 131 or newer.
+Math is rendered to inline SVG with MathJax during the build, so Node.js 22 or newer is required.
Set `BROWSER_PATH` if Chrome or Chromium is installed in a nonstandard location.
## License
diff --git a/_config.yml b/_config.yml
index 1263167..8b9391e 100644
--- a/_config.yml
+++ b/_config.yml
@@ -42,5 +42,8 @@ plugins:
exclude:
- Gemfile
- Gemfile.lock
+ - node_modules/
+ - package-lock.json
+ - package.json
- bin/
- design.md
diff --git a/_layouts/base.html b/_layouts/base.html
index 48f5ee6..a8c3626 100644
--- a/_layouts/base.html
+++ b/_layouts/base.html
@@ -117,17 +117,47 @@
}
blockquote { margin: 1.2em 2em; font-style: italic; }
+ {%- if page.math %}
/* Keep wide display math inside the article instead of widening the page. */
- .katex-display {
+ mjx-container[jax="SVG"][display="true"] {
max-width: 100%;
overflow-x: auto;
overflow-y: hidden;
}
- .katex-display > .katex {
- max-width: none;
+ /* Select each path-only formula atomically and copy its TeX source. */
+ mjx-container[jax="SVG"] {
+ cursor: text;
+ user-select: all;
+ -webkit-user-select: all;
}
+ mjx-container[jax="SVG"] .math-tex-source {
+ position: absolute;
+ width: 1px;
+ height: 1px;
+ overflow: hidden;
+ clip: rect(0, 0, 0, 0);
+ clip-path: inset(50%);
+ white-space: nowrap;
+ }
+
+ mjx-container[jax="SVG"].math-tex-selected {
+ background: Highlight;
+ color: HighlightText;
+ }
+
+ mjx-container[jax="SVG"][display="true"].math-tex-selected {
+ background: transparent;
+ color: inherit;
+ }
+
+ mjx-container[jax="SVG"][display="true"].math-tex-selected > svg {
+ background: Highlight;
+ color: HighlightText;
+ }
+ {%- endif %}
+
/* Allow long URLs and inline code to wrap before widening the viewport. */
main a,
main code {
@@ -197,11 +227,6 @@
thead { border-color: currentColor; }
}
- {%- if page.math -%}
-
-
-
- {%- endif -%}
@@ -261,6 +286,9 @@
AI disclosure: The ideas, observations, and memories are mine. I write them down. I compress them. Claude helps with language, wording, and clarity.
+ {%- if page.math %}
+
+ {%- endif %}
diff --git a/_plugins/build_time_math.rb b/_plugins/build_time_math.rb
new file mode 100644
index 0000000..8fb6482
--- /dev/null
+++ b/_plugins/build_time_math.rb
@@ -0,0 +1,44 @@
+require "open3"
+
+module BuildTimeMath
+ DELIMITERS = /\$\$.*?\$\$|\$(?:\\.|[^$])+?\$|\\\(.*?\\\)|\\\[.*?\\\]/m
+
+ def self.render_html(html, site:, label:, fragment: false)
+ return html unless html.match?(DELIMITERS)
+
+ script = File.join(site.source, "bin", "render-math-svg.mjs")
+ command = ["node", script]
+ command << "--fragment" if fragment
+ output, errors, status = Open3.capture3(*command, stdin_data: html)
+
+ unless status.success?
+ raise Jekyll::Errors::FatalException,
+ "Math SVG rendering failed for #{label}:\n#{errors}"
+ end
+
+ output
+ end
+
+ def self.render(page)
+ return unless page.data["math"]
+
+ page.output = render_html(page.output, site: page.site, label: page.path)
+ end
+end
+
+module BuildTimeMathFilter
+ def render_math_svg(html)
+ site = @context.registers[:site]
+ BuildTimeMath.render_html(html, site: site, label: "RSS item", fragment: true)
+ end
+end
+
+Liquid::Template.register_filter(BuildTimeMathFilter)
+
+Jekyll::Hooks.register :pages, :post_render do |page|
+ BuildTimeMath.render(page)
+end
+
+Jekyll::Hooks.register :documents, :post_render do |document|
+ BuildTimeMath.render(document)
+end
diff --git a/assets/blog/lean_numberline_two_cases.svg b/assets/blog/lean_numberline_two_cases.svg
index e0fdbc8..345208c 100644
--- a/assets/blog/lean_numberline_two_cases.svg
+++ b/assets/blog/lean_numberline_two_cases.svg
@@ -7,6 +7,12 @@
.tick { stroke: #181818; stroke-width: 1.4; }
.brace{ stroke: #181818; stroke-width: 1.4; fill: none; }
.dot { fill: #181818; }
+
+ @media (prefers-color-scheme: dark) {
+ :root { fill: #fff; }
+ .ax, .tick, .brace { stroke: #fff; }
+ .dot, marker path { fill: #fff; }
+ }
diff --git a/bin/build b/bin/build
index c27df72..4906a07 100755
--- a/bin/build
+++ b/bin/build
@@ -1,4 +1,5 @@
#!/usr/bin/env bash
set -euo pipefail
+npm ci
GENERATE_PDFS=1 bundle exec jekyll build "$@"
diff --git a/bin/render-math-svg.mjs b/bin/render-math-svg.mjs
new file mode 100644
index 0000000..8e174ef
--- /dev/null
+++ b/bin/render-math-svg.mjs
@@ -0,0 +1,104 @@
+#!/usr/bin/env node
+
+import { readFileSync } from "node:fs";
+import { mathjax } from "@mathjax/src/js/mathjax.js";
+import { TeX } from "@mathjax/src/js/input/tex.js";
+import { SVG } from "@mathjax/src/js/output/svg.js";
+import { liteAdaptor } from "@mathjax/src/js/adaptors/liteAdaptor.js";
+import { RegisterHTMLHandler } from "@mathjax/src/js/handlers/html.js";
+import { AssistiveMmlHandler } from "@mathjax/src/js/a11y/assistive-mml.js";
+import "@mathjax/src/js/util/asyncLoad/esm.js";
+
+import "@mathjax/src/js/input/tex/base/BaseConfiguration.js";
+import "@mathjax/src/js/input/tex/ams/AmsConfiguration.js";
+import "@mathjax/src/js/input/tex/color/ColorConfiguration.js";
+import "@mathjax/src/js/input/tex/newcommand/NewcommandConfiguration.js";
+import "@mathjax/src/js/input/tex/textmacros/TextMacrosConfiguration.js";
+
+const EM = 15;
+const EX = 7.5;
+const fragment = process.argv.includes("--fragment");
+const adaptor = liteAdaptor({ fontSize: EM });
+
+AssistiveMmlHandler(RegisterHTMLHandler(adaptor));
+
+const tex = new TeX({
+ packages: ["base", "ams", "color", "newcommand", "textmacros"],
+ inlineMath: [["$", "$"], ["\\(", "\\)"]],
+ displayMath: [["$$", "$$"], ["\\[", "\\]"]],
+ processEscapes: true,
+ formatError(jax, error) {
+ throw error;
+ }
+});
+const svg = new SVG({
+ exFactor: EX / EM,
+ fontCache: "none",
+ linebreaks: { inline: false }
+});
+const document = mathjax.document(readFileSync(0, "utf8"), {
+ InputJax: tex,
+ OutputJax: svg
+});
+
+await document.renderPromise();
+
+const root = adaptor.root(document.document);
+const errors = adaptor.tags(root, "g")
+ .filter((node) => adaptor.getAttribute(node, "data-mml-node") === "merror");
+if (errors.length > 0) {
+ const messages = errors.map((node) => adaptor.getAttribute(node, "data-mjx-error"));
+ throw new Error(`MathJax could not render:\n${messages.join("\n")}`);
+}
+
+for (const container of adaptor.tags(root, "mjx-container")) {
+ const math = adaptor.tags(container, "g")
+ .find((node) => adaptor.getAttribute(node, "data-mml-node") === "math");
+ const source = math && adaptor.getAttribute(math, "data-latex");
+ if (!source) continue;
+
+ if (fragment) {
+ const graphic = adaptor.tags(container, "svg")[0];
+ if (!graphic) continue;
+
+ const display = adaptor.getAttribute(container, "display") === "true";
+ const graphicStyle = adaptor.getAttribute(graphic, "style") || "";
+ const width = adaptor.getAttribute(graphic, "width");
+ const height = adaptor.getAttribute(graphic, "height");
+ const layout = display
+ ? `display:block;margin:.7em auto;padding:0;border:0;max-width:100%;width:${width};height:auto;`
+ : `display:inline-block;margin:0;padding:0;border:0;max-width:none;width:${width};height:${height};`;
+ adaptor.setAttribute(graphic, "style", `${graphicStyle}${layout}`);
+ adaptor.setAttribute(graphic, "aria-label", source);
+ adaptor.removeAttribute(graphic, "aria-hidden");
+ adaptor.replace(graphic, container);
+ continue;
+ }
+
+ const selectableSource = source.replace(/\r?\n[\t ]*/g, " ");
+ const text = adaptor.node("span", {
+ "aria-hidden": "true",
+ class: "math-tex-source"
+ }, [adaptor.text(selectableSource)]);
+ adaptor.append(container, text);
+}
+
+for (const background of adaptor.tags(root, "rect")) {
+ if (adaptor.getAttribute(background, "data-bgcolor") !== "true") continue;
+
+ const content = adaptor.next(background);
+ if (content && adaptor.kind(content) === "g") {
+ adaptor.setAttribute(content, "fill", "#1c1c1a");
+ adaptor.setAttribute(content, "stroke", "#1c1c1a");
+ }
+}
+
+const html = adaptor.outerHTML(root)
+ .replace(/@font-face \/\* zero \*\/ \{[\s\S]*?\n\}/, "");
+
+if (fragment) {
+ process.stdout.write(adaptor.innerHTML(adaptor.body(document.document)));
+} else {
+ process.stdout.write(adaptor.doctype(document.document));
+ process.stdout.write(html);
+}
diff --git a/package-lock.json b/package-lock.json
new file mode 100644
index 0000000..f456b53
--- /dev/null
+++ b/package-lock.json
@@ -0,0 +1,198 @@
+{
+ "name": "build-time-katex",
+ "lockfileVersion": 3,
+ "requires": true,
+ "packages": {
+ "": {
+ "dependencies": {
+ "@mathjax/src": "4.1.3"
+ },
+ "engines": {
+ "node": ">=22"
+ }
+ },
+ "node_modules/@mathjax/src": {
+ "version": "4.1.3",
+ "resolved": "https://registry.npmjs.org/@mathjax/src/-/src-4.1.3.tgz",
+ "integrity": "sha512-rIrWquuBSoJuoMBdC/1qD+AUHTorlccPicoVy6P2xbUgnuDBpCcpbHtOAsB8L3hdCHtNBg92lF8e3Fz+pkcQbw==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@mathjax/mathjax-newcm-font": "4.1.3",
+ "mhchemparser": "^4.2.1",
+ "mj-context-menu": "^1.0.0",
+ "speech-rule-engine": "5.0.0-rc.4"
+ }
+ },
+ "node_modules/@mathjax/src/node_modules/@mathjax/mathjax-newcm-font": {
+ "version": "4.1.3",
+ "resolved": "https://registry.npmjs.org/@mathjax/mathjax-newcm-font/-/mathjax-newcm-font-4.1.3.tgz",
+ "integrity": "sha512-gzAB3dFHilHX1l5x2xUqRL+1jDQt3Fyza1DkEMVXWC4E8SvsGdlgEza47HYi2WhVcgfkvf4zgUGzuhbq3Pjlew==",
+ "license": "Apache-2.0"
+ },
+ "node_modules/@xmldom/xmldom": {
+ "version": "0.9.12",
+ "resolved": "https://registry.npmjs.org/@xmldom/xmldom/-/xmldom-0.9.12.tgz",
+ "integrity": "sha512-5AXjrcMClTryPe9LgZrygpB1lj7s0S9E0+W+AHaVKAVyHanafK86iPSvG5xHVSp/jC+VH1UXu0TAEmY279xH7A==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=14.6"
+ }
+ },
+ "node_modules/balanced-match": {
+ "version": "4.0.4",
+ "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz",
+ "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==",
+ "license": "MIT",
+ "engines": {
+ "node": "18 || 20 || >=22"
+ }
+ },
+ "node_modules/brace-expansion": {
+ "version": "5.0.12",
+ "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.12.tgz",
+ "integrity": "sha512-YovQ3rzhaLMIrDjNDMkNS01tea93qhEhG5xy8f6+R0l+dw3Ki+5sCoIoI942iuLZTHWogWktgwVDhU09iNEimQ==",
+ "license": "MIT",
+ "dependencies": {
+ "balanced-match": "^4.0.2"
+ },
+ "engines": {
+ "node": "20 || >=22"
+ }
+ },
+ "node_modules/commander": {
+ "version": "14.0.3",
+ "resolved": "https://registry.npmjs.org/commander/-/commander-14.0.3.tgz",
+ "integrity": "sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=20"
+ }
+ },
+ "node_modules/glob": {
+ "version": "13.0.6",
+ "resolved": "https://registry.npmjs.org/glob/-/glob-13.0.6.tgz",
+ "integrity": "sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw==",
+ "license": "BlueOak-1.0.0",
+ "dependencies": {
+ "minimatch": "^10.2.2",
+ "minipass": "^7.1.3",
+ "path-scurry": "^2.0.2"
+ },
+ "engines": {
+ "node": "18 || 20 || >=22"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/isaacs"
+ }
+ },
+ "node_modules/mhchemparser": {
+ "version": "4.2.1",
+ "resolved": "https://registry.npmjs.org/mhchemparser/-/mhchemparser-4.2.1.tgz",
+ "integrity": "sha512-kYmyrCirqJf3zZ9t/0wGgRZ4/ZJw//VwaRVGA75C4nhE60vtnIzhl9J9ndkX/h6hxSN7pjg/cE0VxbnNM+bnDQ==",
+ "license": "Apache-2.0"
+ },
+ "node_modules/minimatch": {
+ "version": "10.2.6",
+ "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.6.tgz",
+ "integrity": "sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A==",
+ "license": "BlueOak-1.0.0",
+ "dependencies": {
+ "brace-expansion": "^5.0.8"
+ },
+ "engines": {
+ "node": "18 || 20 || >=22"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/isaacs"
+ }
+ },
+ "node_modules/minipass": {
+ "version": "7.1.3",
+ "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz",
+ "integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==",
+ "license": "BlueOak-1.0.0",
+ "engines": {
+ "node": ">=16 || 14 >=14.17"
+ }
+ },
+ "node_modules/mj-context-menu": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/mj-context-menu/-/mj-context-menu-1.0.0.tgz",
+ "integrity": "sha512-OSgBFQRCVhrZwRa9lhqnKcJU92dd/YXgBjup0uyeuj9bOaVsf4myOyrjU4PfhYkdDk6AqVUTov7v5uFMvIbduA==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "rimraf": "^6.0.1"
+ }
+ },
+ "node_modules/package-json-from-dist": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz",
+ "integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==",
+ "license": "BlueOak-1.0.0"
+ },
+ "node_modules/path-scurry": {
+ "version": "2.0.2",
+ "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-2.0.2.tgz",
+ "integrity": "sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg==",
+ "license": "BlueOak-1.0.0",
+ "dependencies": {
+ "lru-cache": "^11.0.0",
+ "minipass": "^7.1.2"
+ },
+ "engines": {
+ "node": "18 || 20 || >=22"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/isaacs"
+ }
+ },
+ "node_modules/path-scurry/node_modules/lru-cache": {
+ "version": "11.5.2",
+ "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.2.tgz",
+ "integrity": "sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g==",
+ "license": "BlueOak-1.0.0",
+ "engines": {
+ "node": "20 || >=22"
+ }
+ },
+ "node_modules/rimraf": {
+ "version": "6.1.3",
+ "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-6.1.3.tgz",
+ "integrity": "sha512-LKg+Cr2ZF61fkcaK1UdkH2yEBBKnYjTyWzTJT6KNPcSPaiT7HSdhtMXQuN5wkTX0Xu72KQ1l8S42rlmexS2hSA==",
+ "license": "BlueOak-1.0.0",
+ "dependencies": {
+ "glob": "^13.0.3",
+ "package-json-from-dist": "^1.0.1"
+ },
+ "bin": {
+ "rimraf": "dist/esm/bin.mjs"
+ },
+ "engines": {
+ "node": "20 || >=22"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/isaacs"
+ }
+ },
+ "node_modules/speech-rule-engine": {
+ "version": "5.0.0-rc.4",
+ "resolved": "https://registry.npmjs.org/speech-rule-engine/-/speech-rule-engine-5.0.0-rc.4.tgz",
+ "integrity": "sha512-3dFcH2QtQNhtvUUc09TxoaEK4WG03B9Z57krFG9jocrKykKGu35BhIkWr0vgEAxZZomEWkeluff69y/EbYzP4Q==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@xmldom/xmldom": "^0.9.10",
+ "commander": "^14.0.3",
+ "wicked-good-xpath": "^1.3.0"
+ },
+ "bin": {
+ "sre": "bin/sre"
+ }
+ },
+ "node_modules/wicked-good-xpath": {
+ "version": "1.3.0",
+ "resolved": "https://registry.npmjs.org/wicked-good-xpath/-/wicked-good-xpath-1.3.0.tgz",
+ "integrity": "sha512-Gd9+TUn5nXdwj/hFsPVx5cuHHiF5Bwuc30jZ4+ronF1qHK5O7HD0sgmXWSEgwKquT3ClLoKPVbO6qGwVwLzvAw==",
+ "license": "MIT"
+ }
+ }
+}
diff --git a/package.json b/package.json
new file mode 100644
index 0000000..8d434de
--- /dev/null
+++ b/package.json
@@ -0,0 +1,9 @@
+{
+ "private": true,
+ "engines": {
+ "node": ">=22"
+ },
+ "dependencies": {
+ "@mathjax/src": "4.1.3"
+ }
+}
diff --git a/rss.xml b/rss.xml
index 12c4c46..62ace86 100644
--- a/rss.xml
+++ b/rss.xml
@@ -17,11 +17,12 @@ permalink: /rss.xml
{%- for post in site.posts %}
{%- assign feedback_target = site.url | append: post.url %}
{%- capture feedback_link %}
Challenge this post (anonymous)
{% endcapture %}
+ {%- assign description = post.content | append: feedback_link | render_math_svg %}
-
{{ post.title | xml_escape }}
{{ site.url }}{{ post.url }}
{{ site.url }}{{ post.url }}
- {{ post.content | append: feedback_link | xml_escape }}
+ {{ description | xml_escape }}
{{ post.date | date_to_rfc822 }}
{%- for tag in post.tags %}
{{ tag | xml_escape }}
@@ -34,11 +35,12 @@ permalink: /rss.xml
{%- assign slug = entry.title | replace: ".", "" | slugify %}
{%- assign feedback_target = site.url | append: "/threads/#" | append: slug %}
{%- capture feedback_link %}
Challenge this thread (anonymous)
{% endcapture %}
+ {%- assign description = entry.content | append: feedback_link | render_math_svg %}
-
Threads: {{ entry.title | xml_escape }}
{{ site.url }}/threads/#{{ slug }}
{{ site.url }}/threads/#{{ slug }}
-
+
{{ entry.date | date_to_rfc822 }}
threads
@@ -46,11 +48,12 @@ permalink: /rss.xml
{%- for project in site.projects %}
{%- assign feedback_target = site.url | append: project.url %}
{%- capture feedback_link %}
Challenge this project (anonymous)
{% endcapture %}
+ {%- assign description = project.content | markdownify | replace: 'src="/', 'src="https://cb341.dev/' | replace: 'href="/', 'href="https://cb341.dev/' | append: feedback_link | render_math_svg %}
-
Project: {{ project.title | xml_escape }}
{{ site.url }}{{ project.url }}
{{ site.url }}{{ project.url }}
-
+
{{ project.date | date_to_rfc822 }}
project
{%- for tag in project.tags %}