diff --git a/README.md b/README.md
index 2c7c6151..1b8fdf12 100644
--- a/README.md
+++ b/README.md
@@ -113,12 +113,32 @@ eval "$(gojekyll --completion-script-zsh)"
This project works on the GitHub Pages sites that I and other contributors care
about. It looks credible on a spot-check of other Jekyll sites.
+### Math Support
+
+gojekyll supports mathematical expressions using MathJax or KaTeX, compatible with Jekyll/kramdown syntax:
+
+- Use `$$...$$` delimiters for both inline and display math
+- Math expressions are preserved in the HTML output for client-side rendering
+- Works with both MathJax 3 and KaTeX
+
+**Example usage:**
+
+```markdown
+Inline math: The equation $$E=mc^2$$ is famous.
+
+Display math:
+$$
+\int_0^\infty e^{-x} dx = 1
+$$
+```
+
+**Setup:** Add MathJax or KaTeX scripts to your layout templates. See `example/_layouts/math.html` and `example/math-example.md` for complete examples.
+
### Current Limitations
Missing features:
- Pagination
-- Math
- Plugin system. ([Some individual plugins](./docs/plugins.md) are emulated.)
- Liquid is run in strict mode: undefined filters and variables are errors.
- Missing markdown features:
diff --git a/example/_layouts/math.html b/example/_layouts/math.html
new file mode 100644
index 00000000..7455293f
--- /dev/null
+++ b/example/_layouts/math.html
@@ -0,0 +1,61 @@
+
+
+
+
+
+ {{ page.title | default: site.title }}
+
+
+
+
+
+
+
+
+
+
+ {{ content }}
+
+
diff --git a/example/math-example.md b/example/math-example.md
new file mode 100644
index 00000000..6ce36751
--- /dev/null
+++ b/example/math-example.md
@@ -0,0 +1,67 @@
+---
+layout: math
+title: Math Rendering Example
+---
+
+# Math Rendering with gojekyll
+
+This page demonstrates mathematical expression rendering using MathJax or KaTeX.
+
+## Inline Math
+
+The famous equation $$E=mc^2$$ shows the relationship between energy and mass.
+
+Here's another example: $$x_0 = \frac{-b \pm \sqrt{b^2 - 4ac}}{2a}$$
+
+## Display Math
+
+The Fundamental Theorem of Calculus:
+
+$$
+\int_a^b f'(x) dx = f(b) - f(a)
+$$
+
+Maxwell's Equations:
+
+$$
+\begin{aligned}
+\nabla \cdot \mathbf{E} &= \frac{\rho}{\epsilon_0} \\
+\nabla \cdot \mathbf{B} &= 0 \\
+\nabla \times \mathbf{E} &= -\frac{\partial \mathbf{B}}{\partial t} \\
+\nabla \times \mathbf{B} &= \mu_0\left(\mathbf{J} + \epsilon_0 \frac{\partial \mathbf{E}}{\partial t}\right)
+\end{aligned}
+$$
+
+## Matrices
+
+$$
+\begin{bmatrix}
+a & b \\
+c & d
+\end{bmatrix}
+\begin{bmatrix}
+x \\
+y
+\end{bmatrix}
+=
+\begin{bmatrix}
+ax + by \\
+cx + dy
+\end{bmatrix}
+$$
+
+## Complex Expression
+
+The probability density function of the normal distribution:
+
+$$
+f(x | \mu, \sigma^2) = \frac{1}{\sqrt{2\pi\sigma^2}} e^{-\frac{(x-\mu)^2}{2\sigma^2}}
+$$
+
+## Note
+
+Math expressions use the `$$...$$` delimiter for both inline and display math.
+- **Inline**: `$$E=mc^2$$` renders as $$E=mc^2$$
+- **Display**: Use `$$...$$` on its own lines for centered equations
+
+The delimiters are preserved in the HTML and rendered by MathJax or KaTeX on the client side.
diff --git a/go.mod b/go.mod
index 2ddde7ca..b5b16eff 100644
--- a/go.mod
+++ b/go.mod
@@ -10,6 +10,7 @@ require (
github.com/bep/godartsass/v2 v2.5.0
github.com/danog/blackfriday/v2 v2.1.6
github.com/fsnotify/fsnotify v1.9.0
+ github.com/gohugoio/hugo-goldmark-extensions/passthrough v0.3.1
github.com/google/go-github v17.0.0+incompatible
github.com/jaschaephraim/lrserver v0.0.0-20240306232639-afed386b3640
github.com/k0kubun/pp v3.0.1+incompatible
@@ -20,6 +21,7 @@ require (
github.com/radovskyb/watcher v1.0.7
github.com/stretchr/testify v1.11.1
github.com/tdewolff/minify v2.3.6+incompatible
+ github.com/yuin/goldmark v1.7.13
golang.org/x/net v0.46.0
golang.org/x/oauth2 v0.30.0
gopkg.in/yaml.v2 v2.4.0
diff --git a/go.sum b/go.sum
index 4a5ef08b..d9df656f 100644
--- a/go.sum
+++ b/go.sum
@@ -183,8 +183,8 @@ github.com/fatih/structtag v1.2.0 h1:/OdNE99OxoI/PqaW/SuSK9uxxT3f/tcSZgon/ssNSx4
github.com/fatih/structtag v1.2.0/go.mod h1:mBJUNpUnHmRKrKlQQlmCrh5PuhftFbNv8Ys4/aAZl94=
github.com/firefart/nonamedreturns v1.0.6 h1:vmiBcKV/3EqKY3ZiPxCINmpS431OcE1S47AQUwhrg8E=
github.com/firefart/nonamedreturns v1.0.6/go.mod h1:R8NisJnSIpvPWheCq0mNRXJok6D8h7fagJTF8EMEwCo=
-github.com/frankban/quicktest v1.14.3 h1:FJKSZTDHjyhriyC81FLQ0LY93eSai0ZyR/ZIkd3ZUKE=
-github.com/frankban/quicktest v1.14.3/go.mod h1:mgiwOwqx65TmIk1wJ6Q7wvnVMocbUorkibMOrVTHZps=
+github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8=
+github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k=
github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=
github.com/fzipp/gocyclo v0.6.0 h1:lsblElZG7d3ALtGMx9fmxeTKZaLLpU8mET09yN4BBLo=
@@ -239,6 +239,8 @@ github.com/godoc-lint/godoc-lint v0.10.1/go.mod h1:KleLcHu/CGSvkjUH2RvZyoK1MBC7p
github.com/gofrs/flock v0.13.0 h1:95JolYOvGMqeH31+FC7D2+uULf6mG61mEZ/A8dRYMzw=
github.com/gofrs/flock v0.13.0/go.mod h1:jxeyy9R1auM5S6JYDBhDt+E2TCo7DkratH4Pgi8P+Z0=
github.com/gogo/protobuf v1.1.1/go.mod h1:r8qH/GZQm5c6nD/R0oafs1akxWv10x8SbQlK7atdtwQ=
+github.com/gohugoio/hugo-goldmark-extensions/passthrough v0.3.1 h1:nUzXfRTszLliZuN0JTKeunXTRaiFX6ksaWP0puLLYAY=
+github.com/gohugoio/hugo-goldmark-extensions/passthrough v0.3.1/go.mod h1:Wy8ThAA8p2/w1DY05vEzq6EIeI2mzDjvHsu7ULBVwog=
github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q=
github.com/golang/groupcache v0.0.0-20190702054246-869f871628b6/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
github.com/golang/groupcache v0.0.0-20191227052852-215e87163ea7/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
@@ -664,6 +666,8 @@ github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9dec
github.com/yuin/goldmark v1.3.5/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k=
github.com/yuin/goldmark v1.4.1/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k=
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
+github.com/yuin/goldmark v1.7.13 h1:GPddIs617DnBLFFVJFgpo1aBfe/4xcvMc3SB5t/D0pA=
+github.com/yuin/goldmark v1.7.13/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg=
gitlab.com/bosi/decorder v0.4.2 h1:qbQaV3zgwnBZ4zPMhGLW4KZe7A7NwxEhJx39R3shffo=
gitlab.com/bosi/decorder v0.4.2/go.mod h1:muuhHoaJkA9QLcYHq4Mj8FJUwDZ+EirSHRiaTcTf6T8=
go-simpler.org/assert v0.9.0 h1:PfpmcSvL7yAnWyChSjOz6Sp6m9j5lyK8Ok9pEL31YkQ=
diff --git a/renderers/markdown.go b/renderers/markdown.go
index b54e36c7..5c0d74b6 100644
--- a/renderers/markdown.go
+++ b/renderers/markdown.go
@@ -1,32 +1,45 @@
package renderers
import (
- blackfriday "github.com/danog/blackfriday/v2"
+ "bytes"
+
+ "github.com/gohugoio/hugo-goldmark-extensions/passthrough"
"github.com/osteele/gojekyll/utils"
+ "github.com/yuin/goldmark"
+ "github.com/yuin/goldmark/extension"
+ "github.com/yuin/goldmark/parser"
+ "github.com/yuin/goldmark/renderer/html"
)
-const blackfridayFlags = 0 |
- blackfriday.UseXHTML |
- blackfriday.Smartypants |
- blackfriday.SmartypantsFractions |
- blackfriday.SmartypantsDashes |
- blackfriday.SmartypantsLatexDashes |
- blackfriday.FootnoteReturnLinks
-
-const blackfridayExtensions = 0 |
- blackfriday.NoIntraEmphasis |
- blackfriday.Tables |
- blackfriday.FencedCode |
- blackfriday.Autolink |
- blackfriday.Strikethrough |
- blackfriday.SpaceHeadings |
- blackfriday.HeadingIDs |
- blackfriday.BackslashLineBreak |
- blackfriday.DefinitionLists |
- blackfriday.NoEmptyLineBeforeBlock |
- // added relative to commonExtensions
- blackfriday.AutoHeadingIDs |
- blackfriday.Footnotes
+// createGoldmarkConverter creates a Goldmark markdown converter configured
+// to match Jekyll/kramdown behavior as closely as possible
+func createGoldmarkConverter() goldmark.Markdown {
+ return goldmark.New(
+ goldmark.WithExtensions(
+ extension.GFM, // GitHub Flavored Markdown (includes tables, strikethrough, autolinks)
+ extension.Footnote, // Footnotes support
+ extension.DefinitionList, // Definition lists
+ extension.Typographer, // Smart quotes and dashes (like Smartypants)
+ passthrough.New(passthrough.Config{ // Math delimiters passthrough
+ // Inline math: $$...$$ → preserved as-is for client-side rendering
+ InlineDelimiters: []passthrough.Delimiters{
+ {Open: "$$", Close: "$$"},
+ },
+ // Block/display math: $$...$$ on separate lines
+ BlockDelimiters: []passthrough.Delimiters{
+ {Open: "$$", Close: "$$"},
+ },
+ }),
+ ),
+ goldmark.WithParserOptions(
+ parser.WithAutoHeadingID(), // Automatic heading IDs
+ ),
+ goldmark.WithRendererOptions(
+ html.WithXHTML(), // Use XHTML tags (like Blackfriday's UseXHTML)
+ html.WithUnsafe(), // Allow raw HTML (Jekyll/kramdown compatibility)
+ ),
+ )
+}
func renderMarkdown(md []byte) ([]byte, error) {
return renderMarkdownWithOptions(md, nil)
@@ -54,15 +67,15 @@ func renderMarkdownWithOptions(md []byte, opts *TOCOptions) ([]byte, error) {
opts.MaxLevel = 6
}
- params := blackfriday.HTMLRendererParameters{
- Flags: blackfridayFlags,
+ // Create Goldmark converter and render markdown to HTML
+ converter := createGoldmarkConverter()
+ var buf bytes.Buffer
+ if err := converter.Convert(md, &buf); err != nil {
+ return nil, utils.WrapError(err, "markdown conversion")
}
- renderer := blackfriday.NewHTMLRenderer(params)
- html := blackfriday.Run(
- md,
- blackfriday.WithRenderer(renderer),
- blackfriday.WithExtensions(blackfridayExtensions),
- )
+ html := buf.Bytes()
+
+ // Process inner markdown (for nested markdown rendering)
html, err := renderInnerMarkdown(html)
if err != nil {
return nil, utils.WrapError(err, "markdown")
@@ -81,14 +94,10 @@ func renderMarkdownWithOptions(md []byte, opts *TOCOptions) ([]byte, error) {
}
func _renderMarkdown(md []byte) ([]byte, error) {
- params := blackfriday.HTMLRendererParameters{
- Flags: blackfridayFlags,
+ converter := createGoldmarkConverter()
+ var buf bytes.Buffer
+ if err := converter.Convert(md, &buf); err != nil {
+ return nil, err
}
- renderer := blackfriday.NewHTMLRenderer(params)
- html := blackfriday.Run(
- md,
- blackfriday.WithRenderer(renderer),
- blackfriday.WithExtensions(blackfridayExtensions),
- )
- return html, nil
+ return buf.Bytes(), nil
}
diff --git a/renderers/markdown_attrs.go b/renderers/markdown_attrs.go
index 8d4558cb..705aeb4a 100644
--- a/renderers/markdown_attrs.go
+++ b/renderers/markdown_attrs.go
@@ -5,7 +5,6 @@ import (
"io"
"regexp"
- blackfriday "github.com/danog/blackfriday/v2"
"github.com/osteele/gojekyll/utils"
"golang.org/x/net/html"
)
@@ -152,30 +151,17 @@ loop:
// _renderMarkdownSpan processes inline markdown without creating block-level elements
func _renderMarkdownSpan(md []byte) ([]byte, error) {
- // For span-level processing, we don't want to create block-level elements like paragraphs
- // Instead, we just want inline formatting (bold, italic, links, etc.)
- params := blackfriday.HTMLRendererParameters{
- Flags: blackfridayFlags,
+ // For span-level processing with Goldmark, we just render normally
+ // and then strip the wrapping paragraph tags that Goldmark adds
+ html, err := _renderMarkdown(md)
+ if err != nil {
+ return nil, err
}
- renderer := blackfriday.NewHTMLRenderer(params)
-
- // Use only inline-level extensions for span mode
- inlineExtensions := blackfriday.NoIntraEmphasis |
- blackfriday.Autolink |
- blackfriday.Strikethrough |
- blackfriday.BackslashLineBreak
-
- // Process the content without creating paragraphs - we're handling inline elements
- content := bytes.TrimSpace(md)
- html := blackfriday.Run(
- content,
- blackfriday.WithRenderer(renderer),
- blackfriday.WithExtensions(inlineExtensions),
- )
-
- // Remove any potential wrapping paragraph tags that blackfriday might add
+
+ // Remove wrapping paragraph tags that Goldmark adds
+ html = bytes.TrimSpace(html)
html = bytes.TrimPrefix(html, []byte(""))
- html = bytes.TrimSuffix(html, []byte("
\n"))
+ html = bytes.TrimSuffix(html, []byte(""))
return html, nil
}
diff --git a/renderers/markdown_math_test.go b/renderers/markdown_math_test.go
new file mode 100644
index 00000000..e07cbc72
--- /dev/null
+++ b/renderers/markdown_math_test.go
@@ -0,0 +1,108 @@
+package renderers
+
+import (
+ "strings"
+ "testing"
+
+ "github.com/stretchr/testify/require"
+)
+
+func TestMathDelimiters(t *testing.T) {
+ tests := []struct {
+ name string
+ input string
+ contains string // what the output should contain
+ }{
+ {
+ name: "Inline math with $$",
+ input: "The equation $$E=mc^2$$ is famous.",
+ contains: "$$E=mc^2$$",
+ },
+ {
+ name: "Display math block",
+ input: `Some text
+
+$$
+\int_0^\infty e^{-x} dx = 1
+$$
+
+More text`,
+ contains: "$$",
+ },
+ {
+ name: "Math with underscores",
+ input: "The variable $$x_0$$ represents the initial value.",
+ contains: "$$x_0$$",
+ },
+ {
+ name: "Math with asterisks",
+ input: "The expression $$x * y$$ shows multiplication.",
+ contains: "$$x * y$$",
+ },
+ {
+ name: "Complex math expression",
+ input: `$$
+\begin{bmatrix}
+a & b \\
+c & d
+\end{bmatrix}
+$$`,
+ contains: "\\begin{bmatrix}",
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ html, err := renderMarkdown([]byte(tt.input))
+ require.NoError(t, err)
+ htmlStr := string(html)
+
+ // Verify that the math delimiters and content are preserved
+ require.Contains(t, htmlStr, tt.contains,
+ "Expected output to contain math expression")
+
+ // Verify math is NOT converted to HTML entities or modified
+ require.NotContains(t, htmlStr, "<", "Math should not be HTML-escaped")
+ require.NotContains(t, htmlStr, "", "Underscores in math should not create emphasis")
+ })
+ }
+}
+
+func TestMathWithMarkdown(t *testing.T) {
+ input := `# Header
+
+Some **bold** text and $$E=mc^2$$ inline math.
+
+$$
+F = ma
+$$
+
+More _italic_ text.`
+
+ html, err := renderMarkdown([]byte(input))
+ require.NoError(t, err)
+ htmlStr := string(html)
+
+ // Verify markdown is processed
+ require.Contains(t, htmlStr, "bold")
+ require.Contains(t, htmlStr, "italic")
+ require.Contains(t, htmlStr, "")
+ // The $$ should be inside the code block, not treated as math passthrough
+ require.True(t, strings.Contains(htmlStr, "