Migrate from MathJax to KaTeX #31

Open
Groupr wants to merge 17 commits from Katex_Swap into main
14 changed files with 99 additions and 95 deletions

View file

@ -524,8 +524,8 @@ def main(global_config, **settings):
def add_node_order(request):
return request.params.get("order", request.namespace.node_order)
def add_mathjax(request):
return "true" if request.namespace.mathjax else "false"
def add_katex(request):
return "true" if request.namespace.katex else "false"
def add_theme_mode(request):
"""
@ -604,7 +604,7 @@ def main(global_config, **settings):
config.add_request_method(add_page_size, "page_size", reify=True)
config.add_request_method(add_page_offset, "page_offset", reify=True)
config.add_request_method(add_node_order, "node_order", reify=True)
config.add_request_method(add_mathjax, "mathjax", reify=True)
config.add_request_method(add_katex, "katex", reify=True)
config.add_request_method(add_theme_mode, "theme_mode", reify=True)
# all of the web application routes.

View file

@ -229,8 +229,8 @@ def send_immediate_notifications(request, notifications):
},
)
log.info(
"notification frequency=immediately email={}, count={}".format(
notification.user.email,
"notification frequency=immediately username={}, count={}".format(
notification.user.name,
notification.id,
)
)
@ -285,9 +285,9 @@ def send_digest_notifications(request, notification_dict, frequency="daily"):
},
)
log.info(
"notification frequency={} email={}, count={}".format(
"notification frequency={} username={}, count={}".format(
frequency,
recipient_email,
user.name,
notifications_count,
)
)

View file

@ -12,28 +12,14 @@ log = logging.getLogger(__name__)
def remarkbox_tag_acl(namespace):
"""
Returns a tag_acl document, in this form:
Returns a tag_acl document for controlling allowed HTML tags.
tag_acl = {
"script": [
("type", "math/tex; mode=display", "allow"),
],
}
With KaTeX, math is rendered client-side from text delimiters,
so we don't need special script tag handling like we did with MathJax.
"""
# defaultdict which assumes new keys are lists.
tag_acl = default_tag_acl()
allow_mathjax_acl = ("type", "math/tex; mode=display", "allow")
deny_mathjax_acl = ("type", "math/tex; mode=display", "deny")
log.info("Building remarkbox_tag_acl.")
mathjax_acl = deny_mathjax_acl
if namespace.mathjax:
log.info("This Namespace has Mathjax enabled.")
mathjax_acl = allow_mathjax_acl
tag_acl["script"].append(mathjax_acl)
return tag_acl
@ -45,14 +31,15 @@ def make_cleaner_from_namespace(namespace):
)
# add some properties onto the cleaner object.
cleaner.link_protection = namespace.link_protection
cleaner.mathjax = namespace.mathjax
cleaner.katex = namespace.katex
cleaner.whitelist_domains.append(namespace.name)
cleaner.absolute_domain = namespace.name
return cleaner
def markdown_to_html(data, namespace=None):
raw_html = markdown_to_raw_html(data, extra_extensions=["mdx_math"])
# No special math extension needed - KaTeX auto-render handles $ delimiters client-side
raw_html = markdown_to_raw_html(data)
if namespace:
cleaner = make_cleaner_from_namespace(namespace)
else:

View file

@ -30,22 +30,7 @@ def default_cleaner(tag_acl=None):
We use BeautifulSoup to conditionally whitelist or blacklist
tags based on tag attr_name and attr_value pairs.
For example, this will whitelist Mathjax script tags:
tag_acl = {
"script": [
("type", "math/tex; mode=display", "allow"),
],
}
While this example will blacklist Mathjax script tags:
tag_acl = {
"script": [
("type", "math/tex; mode=display", "deny"),
],
}
tag_acl allows controlling which tags are allowed based on attributes.
"""
if tag_acl is None:
tag_acl = {}

View file

@ -53,7 +53,7 @@ PROTECTED_ATTRIBUTES = {
"hide_unverified": False,
"hide_unless_approved": False,
"hide_powered_by": False,
"mathjax": False,
"katex": False,
"link_protection": False,
"ignore_query_string": False,
"reverse_order": False,
@ -96,8 +96,8 @@ class Namespace(RBase, Base):
hide_powered_by = Column(Boolean, default=False)
# should the list of root nodes in this namespace be public or hidden?
public = Column(Boolean, default=False)
# should we enable MathJax?
mathjax = Column(Boolean, default=False)
# should we enable KaTeX for math rendering?
katex = Column(Boolean, default=False)
# should we enable Link Protection to prevent comments from having links?
link_protection = Column(Boolean, default=False)
# check this box if the external site does _not_ use

View file

@ -0,0 +1,30 @@
"""Add katex column in rb_namespace
Revision ID: 7ad8508e50de
Revises: b8f3c9d4e5a1
Create Date: 2025-01-10 00:00:00.000000
"""
# revision identifiers, used by Alembic.
revision = "7ad8508e50de"
down_revision = "b8f3c9d4e5a1"
branch_labels = None
depends_on = None
from alembic import op
import sqlalchemy as sa
def upgrade():
# Add new katex column with default OFF
op.add_column("rb_namespace", sa.Column("katex", sa.Boolean(), server_default=sa.false()))
# Turn katex ON for any namespace that had mathjax ON
op.execute("UPDATE rb_namespace SET katex = 1 WHERE mathjax = 1")
def downgrade():
# Copy katex values back to mathjax
# Note: SQLite doesn't support DROP COLUMN, so we just sync the data back
op.execute("UPDATE rb_namespace SET mathjax = katex")

View file

@ -2,7 +2,7 @@
// previewTimer must live outside the functions.
var previewTimer = null;
function previewAjax(textarea, div, show_raw = false, mathjax = false){
function previewAjax(textarea, div, show_raw = false, katex = false){
// set div to raw textarea while waiting for remote Markdown rendering.
if (show_raw) {
// bust HTML tags like <script> to prevent running evil code.
@ -14,12 +14,12 @@ function previewAjax(textarea, div, show_raw = false, mathjax = false){
clearTimeout(previewTimer);
}
previewTimer = setTimeout(
function() { sendPreview( textarea, div, mathjax ); },
function() { sendPreview( textarea, div, katex ); },
800
);
}
function sendPreview(textarea, div, mathjax=false){
function sendPreview(textarea, div, katex=false){
$.ajaxSetup ({
cache: false
});
@ -29,10 +29,17 @@ function sendPreview(textarea, div, mathjax=false){
$( '#' + div ).load( url, params );
if (mathjax) {
if (katex) {
setTimeout(function(){
// hack: block MathJax rerender for 50ms, give div chance to load.
MathJax.Hub.Queue(["Typeset",MathJax.Hub,div]);
// Give div chance to load before rendering math
if (typeof renderMathInElement !== 'undefined') {
renderMathInElement(document.getElementById(div), {
delimiters: [
{left: '$$', right: '$$', display: true},
{left: '$', right: '$', display: false}
]
});
}
}, 100);
}
}

View file

@ -59,9 +59,9 @@ If checked, disable infinite nesting and limit to only a depth of 2.
If checked, both <code>example.com/1</code> and <code>example.com/1?a=2</code> will load the same thread because <code>a=2</code> is ignored.
<br>
<br>
<label>Enable MathJax:</label>
<input type="checkbox" name="mathjax-checkbox" id="mathjax-checkbox" {% if request.namespace.mathjax %}checked{% endif %}></input>
If checked, allow beautiful math equations with <a href="https://www.mathjax.org/" target="_blank" rel="nofollow">MathJax</a> <span class="status">(could introduce slowness)</span>
<label>Enable KaTeX:</label>
<input type="checkbox" name="katex-checkbox" id="katex-checkbox" {% if request.namespace.katex %}checked{% endif %}></input>
If checked, allow beautiful math equations with <a href="https://katex.org/" target="_blank" rel="nofollow">KaTeX</a> <span class="status">(fast math rendering)</span>
<br>
<code>$$E = mc^2$$</code>
<br>

View file

@ -11,7 +11,7 @@
name = "thread_data"
id = "thread_data_textarea"
class = "common-textarea"
onkeyup = "previewAjax( 'thread_data_textarea', 'preview', show_raw=true, mathjax={{ request.mathjax }} )"
onkeyup = "previewAjax( 'thread_data_textarea', 'preview', show_raw=true, katex={{ request.katex }} )"
placeholder = "What do you want to say? markdown"
required></textarea>

View file

@ -18,7 +18,7 @@
name = "thread_data"
id = "textarea-{{ node.id }}"
class = "common-textarea"
onkeyup = "previewAjax( 'textarea-{{ node.id }}', 'preview-{{ node.id }}', show_raw=true, mathjax={{ request.mathjax }} )"
onkeyup = "previewAjax( 'textarea-{{ node.id }}', 'preview-{{ node.id }}', show_raw=true, katex={{ request.katex }} )"
placeholder = "{% if request.namespace.placeholder_text %}{{ request.namespace.placeholder_text }}{% else %}What do you want to say? markdown{% endif %}"
required></textarea>
@ -60,7 +60,7 @@
name = "thread_data"
id = "edit-textarea-{{ node.id }}"
class = "common-textarea textarea_edit"
onkeyup = "previewAjax( 'edit-textarea-{{ node.id }}', 'node-data-{{ node.id }}', show_raw=false, mathjax={{ request.mathjax }} )"
onkeyup = "previewAjax( 'edit-textarea-{{ node.id }}', 'node-data-{{ node.id }}', show_raw=false, katex={{ request.katex }} )"
placeholder = "{% if request.namespace.placeholder_text %}{{ request.namespace.placeholder_text }}{% else %}What do you want to say? markdown{% endif %}"
required>{{ node.data }}</textarea>

View file

@ -17,13 +17,18 @@
gtag('config', '{{ request.namespace.google_analytics_id }}');
</script>
{%- endif %}
{% if request.namespace and request.namespace.mathjax %}
<!-- references:
* https://docs.mathjax.org/en/v2.7-latest/safe-mode.html
* reference https://docs.mathjax.org/en/v2.7-latest/start.html#using-a-content-delivery-network-cdn
-->
<script type="text/javascript" async
src="https://cdnjs.cloudflare.com/ajax/libs/mathjax/2.7.7/MathJax.js?config=TeX-MML-AM_CHTML,Safe">
{% if request.namespace and request.namespace.katex %}
<!-- KaTeX CSS and JS -->
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/katex@0.16.9/dist/katex.min.css" integrity="sha384-n8MVd4RsNIU0tAv4ct0nTaAbDJwPJzDEaqSD1odI+WdtXRGWt2kTvGFasHpSy3SV" crossorigin="anonymous">
<script defer src="https://cdn.jsdelivr.net/npm/katex@0.16.9/dist/katex.min.js" integrity="sha384-XjKyOOlGwcjNTAIQHIpgOno0Hl1YQqzUOEleOLALmuqehneUG+vnGctmUb0ZY0l8" crossorigin="anonymous"></script>
<!-- KaTeX auto-render extension -->
<script defer src="https://cdn.jsdelivr.net/npm/katex@0.16.9/dist/contrib/auto-render.min.js" integrity="sha384-+VBxd3r6XgURycqtZ117nYw44OOcIax56Z4dCRWbxyPt0Koah1uHoK0o4+/RRE05" crossorigin="anonymous"
onload="renderMathInElement(document.body, {
delimiters: [
{left: '$$', right: '$$', display: true},
{left: '$', right: '$', display: false}
]
});">
</script>
{%- endif %}
{%- include 'google-analytics.j2' %}

View file

@ -138,20 +138,20 @@ class TestNamespace(unittest.TestCase):
self.namespace.subscription_type = "production"
self.namespace.link_protection = True
self.namespace.hide_powered_by = True
self.namespace.mathjax = True
self.namespace.katex = True
self.assertTrue(self.namespace.link_protection)
self.assertTrue(self.namespace.hide_powered_by)
self.assertTrue(self.namespace.mathjax)
self.assertTrue(self.namespace.katex)
self.assertFalse(self.namespace.memoized_attr_protection)
def test_namespace_development_custom_settings(self):
self.namespace.subscription_type = "development"
self.namespace.link_protection = True
self.namespace.hide_powered_by = True
self.namespace.mathjax = True
self.namespace.katex = True
self.assertFalse(self.namespace.link_protection)
self.assertFalse(self.namespace.hide_powered_by)
self.assertFalse(self.namespace.mathjax)
self.assertFalse(self.namespace.katex)
self.assertTrue(self.namespace.memoized_attr_protection)
def test_namespace_simulate_expired_subscription(self):
@ -159,10 +159,10 @@ class TestNamespace(unittest.TestCase):
self.namespace.subscription_type = "production"
self.namespace.link_protection = True
self.namespace.hide_powered_by = True
self.namespace.mathjax = True
self.namespace.katex = True
self.assertTrue(self.namespace.link_protection)
self.assertTrue(self.namespace.hide_powered_by)
self.assertTrue(self.namespace.mathjax)
self.assertTrue(self.namespace.katex)
# next the namespace subscription expires with custom settings.
self.namespace.subscription_type = "development"
@ -170,7 +170,7 @@ class TestNamespace(unittest.TestCase):
self.assertTrue(self.namespace.memoized_attr_protection)
self.assertFalse(self.namespace.link_protection)
self.assertFalse(self.namespace.hide_powered_by)
self.assertFalse(self.namespace.mathjax)
self.assertFalse(self.namespace.katex)
# finally the namespace subscription is renewed with custom settings.
self.namespace.subscription_type = "production"
@ -178,7 +178,7 @@ class TestNamespace(unittest.TestCase):
self.assertFalse(self.namespace.memoized_attr_protection)
self.assertTrue(self.namespace.link_protection)
self.assertTrue(self.namespace.hide_powered_by)
self.assertTrue(self.namespace.mathjax)
self.assertTrue(self.namespace.katex)
class TestNamespaceRequest(unittest.TestCase):

View file

@ -49,36 +49,26 @@ class TestRenderMarkdown(unittest.TestCase):
def test_make_cleaner_from_custom_namespace(self):
namespace = Namespace("russell.ballestrini.net")
namespace.subscription_type = "production"
namespace.mathjax = True
namespace.katex = True
namespace.link_protection = True
cleaner = make_cleaner_from_namespace(namespace)
self.assertTrue(cleaner.link_protection)
self.assertIn("script", cleaner.tags)
self.assertEqual("russell.ballestrini.net", cleaner.absolute_domain)
self.assertIn("russell.ballestrini.net", cleaner.whitelist_domains)
def test_mathjax_enabled_and_disabled(self):
raw_html = markdown_to_raw_html(SAMPLE_MARKDOWN)
def test_katex_enabled_and_disabled(self):
namespace = Namespace("russell.ballestrini.net")
namespace.subscription_type = "production"
# test mathjax enabled.
namespace.mathjax = True
# test katex enabled.
namespace.katex = True
cleaner = make_cleaner_from_namespace(namespace)
self.assertTrue(cleaner.mathjax)
clean_html = clean_raw_html(raw_html, cleaner)
self.assertIn(
'<script type="math/tex; mode=display">y=x^2</script>', clean_html
)
self.assertTrue(cleaner.katex)
# test mathjax disabled.
namespace.mathjax = False
# test katex disabled.
namespace.katex = False
cleaner = make_cleaner_from_namespace(namespace)
self.assertFalse(cleaner.mathjax)
clean_html = clean_raw_html(raw_html, cleaner)
self.assertNotIn(
'<script type="math/tex; mode=display">y=x^2</script>', clean_html
)
self.assertFalse(cleaner.katex)
def test_link_protection_enabled(self):
raw_html = markdown_to_raw_html(SAMPLE_MARKDOWN)

View file

@ -63,7 +63,7 @@ def namespace_settings(request):
link_protection_checkbox = p.get("link-protection-checkbox", "off")
reverse_order_checkbox = p.get("reverse-order-checkbox", "off")
group_conversations_checkbox = p.get("group-conversations-checkbox", "off")
mathjax_checkbox = p.get("mathjax-checkbox", "off")
katex_checkbox = p.get("katex-checkbox", "off")
ignore_query_string_checkbox = p.get("ignore-query-string-checkbox", "off")
hide_powered_by_checkbox = p.get("hide-powered-by-checkbox", "off")
@ -71,7 +71,7 @@ def namespace_settings(request):
link_protection = checkbox_to_bool(link_protection_checkbox)
reverse_order = checkbox_to_bool(reverse_order_checkbox)
group_conversations = checkbox_to_bool(group_conversations_checkbox)
mathjax = checkbox_to_bool(mathjax_checkbox)
katex = checkbox_to_bool(katex_checkbox)
ignore_query_string = checkbox_to_bool(ignore_query_string_checkbox)
hide_powered_by = checkbox_to_bool(hide_powered_by_checkbox)
@ -195,10 +195,10 @@ def namespace_settings(request):
)
)
if mathjax != request.namespace.mathjax:
request.namespace.mathjax = mathjax
if katex != request.namespace.katex:
request.namespace.katex = katex
request.session.flash(
("You turned {} MathJax".format(mathjax_checkbox), "success")
("You turned {} KaTeX".format(katex_checkbox), "success")
)
request.dbsession.add(request.namespace)