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

View File

@ -6,6 +6,7 @@
<link rel="icon" href="%sveltekit.assets%/favicon.ico" /> <link rel="icon" href="%sveltekit.assets%/favicon.ico" />
<link rel="manifest" href="%sveltekit.assets%/manifest.json" /> <link rel="manifest" href="%sveltekit.assets%/manifest.json" />
<meta name="theme-color" content="#0d6efd" /> <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% %sveltekit.head%
</head> </head>
<body data-sveltekit-preload-data="hover"> <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"> <script lang="ts">
import LessonCard from '$components/LessonCard.svelte'; import LessonCard from '$components/LessonCard.svelte';
import { env } from '$env/dynamic/public'; import { env } from '$env/dynamic/public';
import { renderMath } from '$lib/actions/renderMath';
let { data } = $props(); let { data } = $props();
</script> </script>
@ -10,7 +11,7 @@
</svelte:head> </svelte:head>
{#if data.homeContent} {#if data.homeContent}
<section class="home-intro"> <section class="home-intro" use:renderMath>
{@html data.homeContent} {@html data.homeContent}
</section> </section>
{/if} {/if}

View File

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

View File

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

View File

@ -4,4 +4,5 @@ gunicorn==21.2.0
flask-cors==4.0.0 flask-cors==4.0.0
python-dotenv==1.0.0 python-dotenv==1.0.0
requests==2.31.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 # Markdown rendering
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
MD_EXTENSIONS = ['fenced_code', 'tables', 'nl2br', 'toc'] MD_EXTENSIONS = ['fenced_code', 'tables', 'nl2br', 'toc', 'mdx_math']
def _process_circuit_embeds(text): def _process_circuit_embeds(text):
@ -398,4 +398,4 @@ def render_home_content():
parts = home_content.split('---Available_Lessons---') parts = home_content.split('---Available_Lessons---')
main_content = parts[0] if parts else home_content 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'])