feat: implement LaTeX support with KaTeX and python-markdown-math

3 - Integration of KaTeX in frontend (SvelteKit) with a custom
  renderMath` action.

  - Added `python-markdown-math` extension in backend (Flask). to handle math blocks.
  - Configured Vite to handle KaTeX during SSR and browser rendering.
  - Added a LaTeX test lesson and updated home navigation.
  - Supported both inline ($) and block ($$) math rendering,
   including multi-line support.
This commit is contained in:
a2nr 2026-04-26 15:47:11 +07:00
parent dce4916b94
commit b8fb67115e
8 changed files with 76 additions and 6 deletions

View File

@ -12,6 +12,7 @@
"@sveltejs/adapter-node": "^5.0.0",
"@sveltejs/kit": "^2.0.0",
"@sveltejs/vite-plugin-svelte": "^5.0.0",
"@types/katex": "^0.16.7",
"svelte": "^5.0.0",
"svelte-check": "^4.0.0",
"typescript": "^5.0.0",
@ -23,6 +24,7 @@
"@codemirror/theme-one-dark": "^6.1.0",
"codemirror": "^6.0.1",
"highlight.js": "^11.11.0",
"katex": "^0.16.9",
"marked": "^15.0.0"
}
}

View File

@ -6,6 +6,7 @@
<link rel="icon" href="%sveltekit.assets%/favicon.ico" />
<link rel="manifest" href="%sveltekit.assets%/manifest.json" />
<meta name="theme-color" content="#0d6efd" />
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/katex@0.16.9/dist/katex.min.css">
%sveltekit.head%
</head>
<body data-sveltekit-preload-data="hover">

View File

@ -0,0 +1,59 @@
import { browser } from '$app/environment';
import katex from 'katex';
export async function autoRenderMath(node: HTMLElement) {
if (!browser) return;
try {
const renderMathInElement = (await import('katex/dist/contrib/auto-render')).default;
await import('katex/dist/katex.min.css');
// 1. First, handle the standard delimiters
renderMathInElement(node, {
delimiters: [
{ left: '$$', right: '$$', display: true },
{ left: '$', right: '$', display: false },
{ left: '\\(', right: '\\)', display: false },
{ left: '\\[', right: '\\]', display: true }
],
throwOnError: false
});
// 2. Then, handle <script type="math/tex"> tags generated by python-markdown-math
const mathScripts = node.querySelectorAll('script[type^="math/tex"]');
mathScripts.forEach((script) => {
const displayMode = script.getAttribute('type') === 'math/tex; mode=display';
const content = script.textContent || '';
const span = document.createElement('span');
try {
katex.render(content, span, {
displayMode: displayMode,
throwOnError: false
});
script.parentNode?.replaceChild(span, script);
} catch (err) {
console.error('KaTeX rendering error:', err);
}
});
} catch (e) {
console.error('KaTeX failed to load:', e);
}
}
/**
* Svelte action to render LaTeX math in an element using KaTeX auto-render.
*/
export function renderMath(node: HTMLElement) {
if (browser) {
autoRenderMath(node);
}
return {
update() {
if (browser) {
autoRenderMath(node);
}
}
};
}

View File

@ -1,6 +1,7 @@
<script lang="ts">
import LessonCard from '$components/LessonCard.svelte';
import { env } from '$env/dynamic/public';
import { renderMath } from '$lib/actions/renderMath';
let { data } = $props();
</script>
@ -10,7 +11,7 @@
</svelte:head>
{#if data.homeContent}
<section class="home-intro">
<section class="home-intro" use:renderMath>
{@html data.homeContent}
</section>
{/if}

View File

@ -16,6 +16,7 @@
import { createFloatingPanel } from '$actions/floatingPanel.svelte';
import { highlightAllCode } from '$actions/highlightCode';
import { renderCircuitEmbeds } from '$actions/renderCircuitEmbeds';
import { renderMath, autoRenderMath } from '$lib/actions/renderMath';
import { tick, untrack } from 'svelte';
import type { LessonContent } from '$types/lesson';
@ -279,10 +280,12 @@
if (contentEl) {
highlightAllCode(contentEl);
renderCircuitEmbeds(contentEl);
autoRenderMath(contentEl);
}
if (tabsEl) {
highlightAllCode(tabsEl);
renderCircuitEmbeds(tabsEl);
autoRenderMath(tabsEl);
}
});
}
@ -821,7 +824,7 @@
<div class="lesson-layout" class:single-col={float.floating || isMobile}>
<!-- Left: Lesson content (selection & copy prevention) -->
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div class="lesson-content" bind:this={contentEl} use:noSelect
<div class="lesson-content" bind:this={contentEl} use:noSelect use:renderMath
role="region" aria-label="Konten pelajaran"
class:full-width={float.floating || isMobile}
onselectstart={(e) => e.preventDefault()}
@ -869,7 +872,7 @@
/>
<!-- Editor body -->
<div class="editor-body" bind:this={tabsEl}>
<div class="editor-body" bind:this={tabsEl} use:renderMath>
<!-- Info tab panel -->
<!-- svelte-ignore a11y_no_static_element_interactions -->

View File

@ -3,6 +3,9 @@ import { defineConfig } from 'vite';
export default defineConfig({
plugins: [sveltekit()],
ssr: {
noExternal: ['katex']
},
server: {
proxy: {
'/api': {

View File

@ -4,4 +4,5 @@ gunicorn==21.2.0
flask-cors==4.0.0
python-dotenv==1.0.0
requests==2.31.0
flask-limiter==3.5.0
flask-limiter==3.5.0
python-markdown-math==0.8

View File

@ -195,7 +195,7 @@ def get_ordered_lessons_with_learning_objectives(progress=None):
# Markdown rendering
# ---------------------------------------------------------------------------
MD_EXTENSIONS = ['fenced_code', 'tables', 'nl2br', 'toc']
MD_EXTENSIONS = ['fenced_code', 'tables', 'nl2br', 'toc', 'mdx_math']
def _process_circuit_embeds(text):
@ -398,4 +398,4 @@ def render_home_content():
parts = home_content.split('---Available_Lessons---')
main_content = parts[0] if parts else home_content
return md.markdown(main_content, extensions=['fenced_code', 'tables'])
return md.markdown(main_content, extensions=['fenced_code', 'tables', 'mdx_math'])