|
From Hugo in Action by Atishay Jain This article is all about adding LaTeX rendering to a static website built with Hugo. |
Take 40% off Hugo in Action by entering fccjain into the discount code box at checkout at manning.com.
The Acme Corporation is all about digital shapes. Shapes are interlinked with geometry and we need to reach into the arsenal of mathematical notations to express and understand shapes better. LaTeX is the most popular way mathematical notations are represented in scientific papers. As of writing this article, rendering LaTeX isn’t supported by Hugo natively.
A JavaScript library called MathJax (https://www.mathjax.org/) supports rendering LaTeX-based mathematical notations in the web browser. The library is fairly popular and it’s used by most websites that need mathematical notations on the internet. The library scans for the LaTeX based mathematical expressions on the web page and replaces them with the equivalent SVG rendition. Although using MathJax directly is the easiest solution to getting mathematical notations in our website, it comes with a few drawbacks.
- MathJax is fairly large. Although it code splits and tries to get the minimal payload to render what’s needed, it’s still an additional JavaScript that needs to be downloaded and run on every customer’s machine.
- MathJax doesn’t add any interactivity to our website. The output is a static image and there’s no advantage in doing this in the browser. Rendering math on the server is more performant and faster than adding the extra hop to download this library and then performing the calculations and updating the image.
- MathJax on the client isn’t cache friendly. Because it needs to do all the work on demand, we can’t pre-generate the images for our mathematical symbols and save them.
It’s a superior user experience if we can move mathematical rendering to the server and cache the results across page loads or even website rebuilds. Because Hugo doesn’t interface with MathJax directly we need to extend Hugo via the use of external APIs to perform this task. First we create a cloud function and host it. This function takes a LaTeX expression and returns the corresponding SVG image. Next, we call it from Hugo during website compilation and place the search results in our website.
Writing the code to render LaTeX
Because MathJax is written in JavaScript, we use node.js to interface with it. We start by installing the node.js version of MathJax as a dependency of our website. This can be done by adding mathjax
as a dependency in package.hugo.json
. Note that we use the dependency
and not the devDependency
attribute in package.hugo.json
as the dependency is needed for live production code. For this article we use version 3.1.2 of MathJax.
// package.hugo.json { "dependencies": { "mathjax": "3.1.2" } }
Next we need to regenerate package.json via hugo and then install it as a dependency.
hugo mod npm pack npm install
This downloads MathJax as a dependency for our website. Next we create a tex2svg folder that contains the code to convert LaTeX to SVG. This involves initializing MathJax, taking the LaTeX string as an input along with parameters that can be used to perform the SVG conversion and then returning the output as a string. We save our API code to a folder called api
, which has a subfolder called tex2svg that exposes this function. Inside this we have a file called index.js that exposes this as a cloud function.
We use the exports.handler = async function(event, context){}
format as used by AWS Lambda and Netlify functions for this method. This function takes two parameters, event and context. event
is an object containing the following properties:
- path: Path to the request (e.g. /latex2svg)
- httpMethod: Incoming request’s method name (GET, POST, PUT etc.)
- headers: Incoming request headers (e.g. {‘Content-Type’: ‘application/json’} )
- queryStringParameters: query string parameters (e.g. {tex: ‘\frac{1}{2}’})
- body: A JSON string of the request payload. (Empty in a GET request)
- isBase64Encoded: A Boolean flag to indicate if the applicable request payload is encoded in Base64 format.
The code for this script is shared in resources (https://github.com/hugoinaction/ hugoinaction/tree/ch11-resources/2) for this article.
Listing 1. Source code for the cloud function to convert LaTeX to SVG.
// api/latex2svg.js const MathjaxModule = require("mathjax"); ❶ let MathJax = null; module.exports = { /** * Function to handle calls to the API endpoint of the cloud function. */ async handler(event, context) { if (!event.queryStringParameters || !event.queryStringParameters.tex) { ❷ return { statusCode: 400, headers: {'Content-Type': 'application/json'}, body: JSON.stringify({ error: "The required `tex` parameter not supplied.", }) } } if (!MathJax) { ❸ MathJax = await MathjaxModule.init({ loader: { load: ['input/tex', 'output/svg'] } }); } const svg = MathJax.tex2svg(event.queryStringParameters.tex, { ❹ display: event.queryStringParameters.display, em: event.queryStringParameters.em, ex: event.queryStringParameters.ex, containerWidth: event.queryStringParameters.containerWidth, lineWidth: event.queryStringParameters.lineWidth, scale: event.queryStringParameters.scale }); return { ❺ statusCode: 200, headers: {'Content-Type': 'application/json'}, body: JSON.stringify({ data: MathJax.startup.adaptor.outerHTML(svg) }) }; }, }
❶ Import the MathJax dependency
❷ Make sure that the tex parameter is available.
❸ Initialize MathJax only if needed. It takes input as LaTeX and outputs SVG
❹ Pass all parameters from the query string to MathJax
❺ Return 200 with the results as a JSON.
We deliberately output the JSON string instead of raw SVG to get this in Hugo as a JSON object via GetJSON and have the ability to post process it if needed. We can pass additional information if needed to Hugo in the response JSON.
This code can now be deployed to a FAAS solution like AWS Lambda or Netlify Functions to be used from anywhere. For PAAS solutions we need a little more work.
Adding a HTTP server to call this function
Although we have the code to convert LaTeX to SVG, we haven’t tested it locally yet. FAAS providers provide tools like Netlify Dev, AWS SAM or Firebase function emulator to run cloud functions locally to verify and unit test. For this article, instead of using specialized tools, we write some launcher codes to interface with this function. This code also allows us to interface with a Platform a service solution (Heroku in our case) which doesn’t take a function but a full node.js program to run.
We create a new file api.js at the root of our project (creating it within the API folder makes Netlify treat that as a function) which has a simple node.js based HTTP server that responds to HTTP requests and calls this method. (https://github.com/hugoinaction/hugoinaction/tree/ch11-resources/3).
Listing 2. Creating a node.js based HTTP server that can route requests to the right functions for handling in a PAAS solution
const http = require('http'); const querystring = require('querystring'); const latex2svg = require('./api/latex2svg'); const port = process.env.PORT || 3000; ❶ const server = http.createServer().listen(port); server.on('request', async function (req, res) { ❷ const url = new URL(req.url, `http://${req.headers.host}`); const queryStringParameters = url.search && querystring.parse(url.search.slice(1)); const request = { ❸ queryStringParameters, path: url.pathname, httpMethod: req.method, headers: req.headers, body: req.body } let response = { ❹ statusCode: 404, headers: { 'Content-Type': 'application/json'}, body: JSON.stringify({error: "Page not found"}) } try { switch (url.pathname) { ❺ case '/latex2svg': response = await latex2svg.handler(request); break; } } catch(e) { ❻ response.statusCode = 500; response.body = JSON.stringify(e); } res.writeHead(response.statusCode, response.headers); ❼ res.end(response.body); })
❶ Ask for the port from the environment variables or default to 3000.
❷ Setup a HTTP server.
❸ Create a request object compatible with AWS Lambda.
❹ Create a default response.
❺ Create a lightweight router
❻ Gracefully handle exceptions
❼ Send back the response to the client
We can run this code by calling node api
at the root of our project. We can navigate to http://localhost:3000/latex2svg?tex=%5Cfrac%7Ba%7D%7Bb%7D to get the JSON output for the inline version of \frac{a}{b}
LaTeX string. We can append &display=true
to get the display version.
At this point we should update the package.json’s entry for “main” to point to api.js
to allow running as a valid project in the JavaScript ecosystem. We also add a start script to start our API server when we write npm start
. We need to go via the same route of updating package.hugo.json
and run hugo mod npm pack
.
// package.hugo.json { "main": "api.js", "scripts": { "start": "node api.js" } }
Figure 1. JSON response for the LaTeX to SVG conversion API.
Adding some security to prevent unauthorized access
If we publish our function via the launcher script or directly, we add risk by opening up an un-authenticated endpoint which is accessible to entire internet. This can incur a significant cost if it’s used by others without paying us for it. Although we can’t block our endpoint without adding an authentication system or firewall to it, we can make this useless for anyone who doesn’t possess the password. A lightweight security solution can be added by a baked in password into the build system and the API provider and exposing it via environment variables at both the places. Because our password doesn’t go into the client, it’s secure and as long as we use a good password and our service providers are secure, a plain password authentication mechanism works.
Inside of latex2svg.js right before checking for tex
query param, we should check for the password
query param and return unauthorized if this isn’t supplied.
Listing 3. Adding a password to our API to prevent unauthorized access
// api/latex2svg.js ... async handler(event, context) { if (!event.queryStringParameters || !process.env.LATEX2SVG_PASSWORD || ❶ event.queryStringParameters.password !== process.env.LATEX2SVG_PASSWORD) { return { statusCode: 401, ❷ headers: {'Content-Type': 'application/json'}, body: JSON.stringify({ error: "Access Denied.", }) } } ... }
❶ Disallow blank LATEX2SVG_PASSWORD
❷ We use HTTP 401 if the password is wrong. If the password was correct and the user still doesn’t have access HTTP 403 is the correct error code.
For testing locally, we can expose LATEX2SVG_PASSWORD through environment variables on our system as we configure the cloud provider to pass this on to both Hugo as well as our function.
Deploying Netlify functions
Because we already built the function in the way that Netlify understands, there isn’t a lot work needed to deploy it to Netlify. The first step we need to do is tell Netlify the location of the functions folder. To set a folder for Netlify functions, go to Site Settings > Functions > Deploy Settings and click on Edit settings.
Figure 2. Settings for Netlify functions used to specify the folder location where the source code of the website is present.
Next specify api
and click Save.
Figure 3. Specifying the directory for Netlify functions within the deployment settings
We also need to add the LATEX2SVG_PASSWORD variable in the build environment. The steps are go to Site Settings > Build & deploy > Environment > Environment Variables. Click on Edit variables > New Variable. Add a complex password for LATEX2SVG_PASSWORD and click on Save. This password only needs to test out Netlify functions after deployment. You don’t need to remember this password.
Figure 4. Storing the password for limiting unauthorized access to our functions. Environment variables are a great way to keep passwords out of our codebase.
Next we can push our code to Netlify to try out Netlify functions. After the code goes live, we can call https://<endpoint>/.netlify/functions/latex2svg?tex=%5Cfrac%7Ba%7D%7Bb%7D&password=<password>
to get the same response that we got previously when we ran locally.
We can also see our functions in the Functions tab of the Netlify website from where we can get debug logs to figure out what happened on each invocation. Any error which is thrown is also reported. We can put console.log
statements inside of our JavaScript code and see logs in this summary page for all logging we’ve done.
Figure 5. Accessing logs for Netlify functions. The functions tab in Netlify provides access to Netlify functions which can be used to see all the functions active in our website as well as debug errors associated with them.
Figure 6. Detailed logs are available for each function within Netlify.
|
Code Checkpoint. Live at https://ch11-1.hugoinaction.com. Source code at https://github.com/hugoinaction/hugoinaction/tree/ch11-1 |
Stay tuned for part 2, where we deploy to Heroku.
That’s all for this article. If you want to learn more about the book, check it out on Manning’s liveBook platform here.