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 %}