Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
4 changes: 4 additions & 0 deletions .github/workflows/deploy.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,9 @@ _site/
# Bundler
vendor/

# Node.js build dependencies
node_modules/

# macOS-specific files
.DS_Store

Expand Down
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ Personal portfolio and blog built with [Jekyll](https://jekyllrb.com/).

```sh
bundle install
npm install
```

## Run
Expand All @@ -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
Expand Down
3 changes: 3 additions & 0 deletions _config.yml
Original file line number Diff line number Diff line change
Expand Up @@ -42,5 +42,8 @@ plugins:
exclude:
- Gemfile
- Gemfile.lock
- node_modules/
- package-lock.json
- package.json
- bin/
- design.md
44 changes: 36 additions & 8 deletions _layouts/base.html
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -197,11 +227,6 @@
thead { border-color: currentColor; }
}
</style>
{%- if page.math -%}
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/katex@0.16.21/dist/katex.min.css" crossorigin="anonymous">
<script defer src="https://cdn.jsdelivr.net/npm/katex@0.16.21/dist/katex.min.js" crossorigin="anonymous"></script>
<script defer src="https://cdn.jsdelivr.net/npm/katex@0.16.21/dist/contrib/auto-render.min.js" crossorigin="anonymous" onload="renderMathInElement(document.body, {delimiters: [{left: '$$', right: '$$', display: true}, {left: '\\[', right: '\\]', display: true}, {left: '$', right: '$', display: false}, {left: '\\(', right: '\\)', display: false}]});"></script>
{%- endif -%}
</head>
<body>
<div class="wrapper">
Expand Down Expand Up @@ -261,6 +286,9 @@
<p><small>AI disclosure: The ideas, observations, and memories are mine. I write them down. I compress them. <a href="https://claude.ai/new">Claude</a> helps with language, wording, and clarity.</small></p>
</footer>
</div>
{%- if page.math %}
<script>document.addEventListener('selectionchange',()=>{const s=document.getSelection();document.querySelectorAll('mjx-container[jax="SVG"]').forEach(m=>m.classList.toggle('math-tex-selected',s&&!s.isCollapsed&&s.containsNode(m,true)));});</script>
{%- endif %}
<script>document.querySelectorAll('main :is(h2,h3,h4,h5,h6)[id]').forEach(h=>h.innerHTML=`<a href="#${h.id}">${h.innerHTML}</a>`);</script>
<script>document.addEventListener('keydown',e=>{const as=[...document.querySelectorAll('[aria-label="Post navigation"] a')];if(e.key==='ArrowLeft'){const a=as.find(a=>a.textContent.includes('<'));if(a)location.href=a.href;}if(e.key==='ArrowRight'){const a=as.find(a=>a.textContent.includes('>'));if(a)location.href=a.href;}});</script>
</body>
Expand Down
44 changes: 44 additions & 0 deletions _plugins/build_time_math.rb
Original file line number Diff line number Diff line change
@@ -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
6 changes: 6 additions & 0 deletions assets/blog/lean_numberline_two_cases.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
1 change: 1 addition & 0 deletions bin/build
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
#!/usr/bin/env bash
set -euo pipefail

npm ci
GENERATE_PDFS=1 bundle exec jekyll build "$@"
104 changes: 104 additions & 0 deletions bin/render-math-svg.mjs
Original file line number Diff line number Diff line change
@@ -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);
}
Loading
Loading