Merge pull request #13 from dsec-hub/feature/LaTeX-support

added LaTeX support
This commit is contained in:
RythonDev 2026-09-03 09:11:30 +08:00 committed by GitHub
commit 49f02cd07b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
10 changed files with 225 additions and 118 deletions

37
package-lock.json generated
View file

@ -1,14 +1,16 @@
{ {
"name": "dsec-notebook", "name": "dsec-notebook",
"version": "0.0.1", "version": "1.0.0",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "dsec-notebook", "name": "dsec-notebook",
"version": "0.0.1", "version": "1.0.0",
"dependencies": { "dependencies": {
"katex": "^0.18.5",
"markdown-it": "^15.0.1", "markdown-it": "^15.0.1",
"markdown-it-texmath": "^1.0.0",
"resend": "^6.25.0" "resend": "^6.25.0"
}, },
"devDependencies": { "devDependencies": {
@ -6455,6 +6457,31 @@
"node": ">=0.10.0" "node": ">=0.10.0"
} }
}, },
"node_modules/katex": {
"version": "0.18.5",
"resolved": "https://registry.npmjs.org/katex/-/katex-0.18.5.tgz",
"integrity": "sha512-1FU5H3RjGJVj6GT9fMjf2uMIXmPQp232aXS3UEeQzf0dxefHg/CKMvNB3DuOC46LFG/E5PNxrE8dOwB782FDGg==",
"funding": [
"https://opencollective.com/katex",
"https://github.com/sponsors/katex"
],
"license": "MIT",
"dependencies": {
"commander": "^15.0.0"
},
"bin": {
"katex": "cli.js"
}
},
"node_modules/katex/node_modules/commander": {
"version": "15.0.0",
"resolved": "https://registry.npmjs.org/commander/-/commander-15.0.0.tgz",
"integrity": "sha512-z67u4ZhzCL/Tydu1lJARtEZYWbWaN7oYLHbsuzocr6y4N6WZAagG3RQ4FW61V1/0+jImpj293XfrcYnd1qxtPg==",
"license": "MIT",
"engines": {
"node": ">=22.12.0"
}
},
"node_modules/kleur": { "node_modules/kleur": {
"version": "4.1.5", "version": "4.1.5",
"resolved": "https://registry.npmjs.org/kleur/-/kleur-4.1.5.tgz", "resolved": "https://registry.npmjs.org/kleur/-/kleur-4.1.5.tgz",
@ -6823,6 +6850,12 @@
"markdown-it": "bin/markdown-it.mjs" "markdown-it": "bin/markdown-it.mjs"
} }
}, },
"node_modules/markdown-it-texmath": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/markdown-it-texmath/-/markdown-it-texmath-1.0.0.tgz",
"integrity": "sha512-4hhkiX8/gus+6e53PLCUmUrsa6ZWGgJW2XCW6O0ASvZUiezIK900ZicinTDtG3kAO2kon7oUA/ReWmpW2FByxg==",
"license": "MIT"
},
"node_modules/math-intrinsics": { "node_modules/math-intrinsics": {
"version": "1.1.0", "version": "1.1.0",
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",

View file

@ -18,7 +18,9 @@
"lint:fix": "oxlint --fix" "lint:fix": "oxlint --fix"
}, },
"dependencies": { "dependencies": {
"katex": "^0.18.5",
"markdown-it": "^15.0.1", "markdown-it": "^15.0.1",
"markdown-it-texmath": "^1.0.0",
"resend": "^6.25.0" "resend": "^6.25.0"
}, },
"devDependencies": { "devDependencies": {

View file

@ -108,6 +108,20 @@
}); });
} }
function insertMathBlock() {
const { start, end, text } = selection();
const formula = text || "x^2 + y^2 = z^2";
applyEdit((value) => {
const insertion = `$$\n${formula}\n$$`;
const next = value.slice(0, start) + insertion + value.slice(end);
return {
value: next,
start: start + 3,
end: start + 3 + formula.length,
};
});
}
function insertText(text: string) { function insertText(text: string) {
applyEdit((value, start, end) => { applyEdit((value, start, end) => {
const next = value.slice(0, start) + text + value.slice(end); const next = value.slice(0, start) + text + value.slice(end);
@ -210,6 +224,22 @@
<button type="button" class="editor-tool" title="Inline code" onclick={() => wrap("`")}> <button type="button" class="editor-tool" title="Inline code" onclick={() => wrap("`")}>
&lt;/&gt; &lt;/&gt;
</button> </button>
<button
type="button"
class="editor-tool font-serif italic"
title="Inline equation"
onclick={() => wrap("$")}
>
</button>
<button
type="button"
class="editor-tool font-serif italic"
title="Display equation"
onclick={insertMathBlock}
>
</button>
<button <button
type="button" type="button"
class="editor-tool" class="editor-tool"
@ -280,7 +310,8 @@
/> />
<p class="text-faint mt-2 text-xs"> <p class="text-faint mt-2 text-xs">
Markdown supported: **bold**, _italic_, `code`, [links](url), lists, quotes and code Markdown supported: **bold**, _italic_, `code`, [links](url), lists, quotes and code
blocks. Paste or insert images to embed them. blocks. LaTeX equations use $inline$ or $$display$$ notation. Paste or insert images to
embed them.
</p> </p>
{#if uploading} {#if uploading}
<p class="text-muted mt-1 text-xs">Uploading image...</p> <p class="text-muted mt-1 text-xs">Uploading image...</p>

View file

@ -1,5 +1,20 @@
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import { previewMarkdown } from "./markdown"; import { previewMarkdown, renderMarkdown } from "./markdown";
describe("renderMarkdown", () => {
it("renders inline and display LaTeX with KaTeX", () => {
const rendered = renderMarkdown("Euler: $e^{i\\pi} + 1 = 0$\n\n$$\n\\frac{a}{b}\n$$");
expect(rendered).toContain('class="katex"');
expect(rendered).toContain('class="katex-display"');
expect(rendered).toContain("e^{i\\pi} + 1 = 0");
expect(rendered).toContain("\\frac{a}{b}");
});
it("renders invalid LaTeX as an error instead of throwing", () => {
expect(() => renderMarkdown("$\\notacommand{$")).not.toThrow();
});
});
describe("previewMarkdown", () => { describe("previewMarkdown", () => {
it("returns readable text without markdown formatting", () => { it("returns readable text without markdown formatting", () => {

View file

@ -1,4 +1,6 @@
import MarkdownIt from "markdown-it"; import MarkdownIt from "markdown-it";
import texmath from "markdown-it-texmath";
import katex from "katex";
const md = new MarkdownIt({ const md = new MarkdownIt({
html: false, html: false,
@ -6,6 +8,15 @@ const md = new MarkdownIt({
breaks: true, breaks: true,
}); });
md.use(texmath, {
engine: katex,
delimiters: ["dollars", "brackets"],
katexOptions: {
throwOnError: false,
output: "htmlAndMathml",
},
});
export function renderMarkdown(source: string): string { export function renderMarkdown(source: string): string {
return md.render(source ?? ""); return md.render(source ?? "");
} }
@ -14,7 +25,6 @@ export function previewMarkdown(source: string, maxLength = 180): string {
const text = md const text = md
.parse(source ?? "", {}) .parse(source ?? "", {})
.map((token) => { .map((token) => {
console.log(token);
if (token.type === "inline") { if (token.type === "inline") {
return (token.children ?? []) return (token.children ?? [])
.filter((child) => child.type !== "image") .filter((child) => child.type !== "image")

View file

@ -469,9 +469,12 @@ const NOTE_COLUMNS =
function notesCreate( function notesCreate(
db: Db, db: Db,
args: { token: string; title: string; content: string; topicId: string; unitId: string }, args: { token: string; title: string; content: string; topicId?: string; unitId?: string },
) { ) {
const user = requireAuth(db, args.token); const user = requireAuth(db, args.token);
const topicId = args.topicId?.trim() ?? "";
const unitId = args.unitId?.trim() ?? "";
if (!topicId && !unitId) throw new Error("Select a topic or a unit");
const id = newId(); const id = newId();
const now = Date.now(); const now = Date.now();
db.exec("BEGIN"); db.exec("BEGIN");
@ -479,17 +482,7 @@ function notesCreate(
db.prepare( db.prepare(
`INSERT INTO notes (id, title, content, topicId, unitId, authorId, authorName, createdAt, updatedAt, voteCount, commentCount) `INSERT INTO notes (id, title, content, topicId, unitId, authorId, authorName, createdAt, updatedAt, voteCount, commentCount)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 1, 0)`, VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 1, 0)`,
).run( ).run(id, args.title, args.content, topicId, unitId, user._id, user.name, now, now);
id,
args.title,
args.content,
args.topicId,
args.unitId,
user._id,
user.name,
now,
now,
);
db.prepare( db.prepare(
"INSERT INTO votes (id, userId, targetType, targetId, value) VALUES (?, ?, 'note', ?, 1)", "INSERT INTO votes (id, userId, targetType, targetId, value) VALUES (?, ?, 'note', ?, 1)",
).run(newId(), user._id, id); ).run(newId(), user._id, id);
@ -587,9 +580,12 @@ const QUESTION_COLUMNS =
function questionsCreate( function questionsCreate(
db: Db, db: Db,
args: { token: string; title: string; content: string; topicId: string; unitId: string }, args: { token: string; title: string; content: string; topicId?: string; unitId?: string },
) { ) {
const user = requireAuth(db, args.token); const user = requireAuth(db, args.token);
const topicId = args.topicId?.trim() ?? "";
const unitId = args.unitId?.trim() ?? "";
if (!topicId && !unitId) throw new Error("Select a topic or a unit");
const id = newId(); const id = newId();
const now = Date.now(); const now = Date.now();
db.exec("BEGIN"); db.exec("BEGIN");
@ -597,17 +593,7 @@ function questionsCreate(
db.prepare( db.prepare(
`INSERT INTO questions (id, title, content, topicId, unitId, authorId, authorName, createdAt, updatedAt, voteCount, answerCount, solved) `INSERT INTO questions (id, title, content, topicId, unitId, authorId, authorName, createdAt, updatedAt, voteCount, answerCount, solved)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 1, 0, 0)`, VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 1, 0, 0)`,
).run( ).run(id, args.title, args.content, topicId, unitId, user._id, user.name, now, now);
id,
args.title,
args.content,
args.topicId,
args.unitId,
user._id,
user.name,
now,
now,
);
db.prepare( db.prepare(
"INSERT INTO votes (id, userId, targetType, targetId, value) VALUES (?, ?, 'question', ?, 1)", "INSERT INTO votes (id, userId, targetType, targetId, value) VALUES (?, ?, 'question', ?, 1)",
).run(newId(), user._id, id); ).run(newId(), user._id, id);

22
src/markdown-it-texmath.d.ts vendored Normal file
View file

@ -0,0 +1,22 @@
declare module "markdown-it-texmath" {
import type MarkdownIt from "markdown-it";
import type katex from "katex";
type Delimiter =
| "dollars"
| "brackets"
| "doxygen"
| "gitlab"
| "julia"
| "kramdown"
| "beg_end";
interface TexmathOptions {
engine: typeof katex;
delimiters?: Delimiter | Delimiter[];
katexOptions?: katex.KatexOptions;
}
const texmath: MarkdownIt.PluginWithOptions<TexmathOptions>;
export default texmath;
}

View file

@ -1,4 +1,6 @@
@import "tailwindcss"; @import "tailwindcss";
@import "katex/dist/katex.min.css";
@import "markdown-it-texmath/css/texmath.css";
@font-face { @font-face {
font-family: "Inter"; font-family: "Inter";

View file

@ -38,8 +38,9 @@
error = "Title and content are required"; error = "Title and content are required";
return; return;
} }
if (!selectedTopicId) { const hasUnit = useCustomUnit ? !!customUnit.trim() : !!selectedUnitId;
error = "Please select a topic"; if (!selectedTopicId && !hasUnit) {
error = "Please select a topic or a unit";
return; return;
} }
@ -51,7 +52,7 @@
loading = true; loading = true;
try { try {
let unitId = selectedUnitId; let unitId = useCustomUnit ? "" : selectedUnitId;
let unitCode = units.find((unit) => unit._id === unitId)?.code ?? ""; let unitCode = units.find((unit) => unit._id === unitId)?.code ?? "";
if (useCustomUnit && customUnit.trim()) { if (useCustomUnit && customUnit.trim()) {
unitCode = customUnit.trim().toUpperCase(); unitCode = customUnit.trim().toUpperCase();
@ -77,7 +78,7 @@
unitId, unitId,
})) as string; })) as string;
goto(postPath(unitCode, id)); goto(unitCode ? postPath(unitCode, id) : `/notes/${id}`);
} catch (err: any) { } catch (err: any) {
error = err.message ?? "Failed to publish note"; error = err.message ?? "Failed to publish note";
} finally { } finally {
@ -92,9 +93,7 @@
<div class="page"> <div class="page">
<h1 class="text-ink font-serif text-4xl font-medium">Post a note</h1> <h1 class="text-ink font-serif text-4xl font-medium">Post a note</h1>
<p class="text-muted mt-2 mb-10 text-[15px]"> <p class="text-muted mt-2 mb-10 text-[15px]">Share study notes with the Deakin community.</p>
Share study notes with the Deakin community. Focus on a specific topic, not an entire unit.
</p>
<form onsubmit={handleSubmit} class="border-rule space-y-6 border-t pt-8"> <form onsubmit={handleSubmit} class="border-rule space-y-6 border-t pt-8">
<div> <div>
@ -118,10 +117,12 @@
/> />
</div> </div>
<div>
<p class="text-muted mb-3 text-xs">Choose at least one topic or unit.</p>
<div class="grid grid-cols-1 gap-6 sm:grid-cols-2"> <div class="grid grid-cols-1 gap-6 sm:grid-cols-2">
<div> <div>
<label for="topic" class="kicker mb-2 block">Topic</label> <label for="topic" class="kicker mb-2 block">Topic (optional)</label>
<select id="topic" bind:value={selectedTopicId} class="field" required> <select id="topic" bind:value={selectedTopicId} class="field">
<option value="">Select a topic...</option> <option value="">Select a topic...</option>
{#each topics as topic} {#each topics as topic}
<option value={topic._id}>{topic.name}</option> <option value={topic._id}>{topic.name}</option>
@ -163,6 +164,7 @@
{/if} {/if}
</div> </div>
</div> </div>
</div>
{#if error} {#if error}
<p class="text-primary text-sm">{error}</p> <p class="text-primary text-sm">{error}</p>

View file

@ -38,8 +38,9 @@
error = "Title and content are required"; error = "Title and content are required";
return; return;
} }
if (!selectedTopicId) { const hasUnit = useCustomUnit ? !!customUnit.trim() : !!selectedUnitId;
error = "Please select a topic"; if (!selectedTopicId && !hasUnit) {
error = "Please select a topic or a unit";
return; return;
} }
@ -51,7 +52,7 @@
loading = true; loading = true;
try { try {
let unitId = selectedUnitId; let unitId = useCustomUnit ? "" : selectedUnitId;
let unitCode = units.find((unit) => unit._id === unitId)?.code ?? ""; let unitCode = units.find((unit) => unit._id === unitId)?.code ?? "";
if (useCustomUnit && customUnit.trim()) { if (useCustomUnit && customUnit.trim()) {
unitCode = customUnit.trim().toUpperCase(); unitCode = customUnit.trim().toUpperCase();
@ -77,7 +78,7 @@
unitId, unitId,
})) as string; })) as string;
goto(postPath(unitCode, id)); goto(unitCode ? postPath(unitCode, id) : `/questions/${id}`);
} catch (err: any) { } catch (err: any) {
error = err.message ?? "Failed to post question"; error = err.message ?? "Failed to post question";
} finally { } finally {
@ -118,10 +119,12 @@
/> />
</div> </div>
<div>
<p class="text-muted mb-3 text-xs">Choose at least one topic or unit.</p>
<div class="grid grid-cols-1 gap-6 sm:grid-cols-2"> <div class="grid grid-cols-1 gap-6 sm:grid-cols-2">
<div> <div>
<label for="topic" class="kicker mb-2 block">Topic</label> <label for="topic" class="kicker mb-2 block">Topic (optional)</label>
<select id="topic" bind:value={selectedTopicId} class="field" required> <select id="topic" bind:value={selectedTopicId} class="field">
<option value="">Select a topic...</option> <option value="">Select a topic...</option>
{#each topics as topic} {#each topics as topic}
<option value={topic._id}>{topic.name}</option> <option value={topic._id}>{topic.name}</option>
@ -163,6 +166,7 @@
{/if} {/if}
</div> </div>
</div> </div>
</div>
{#if error} {#if error}
<p class="text-primary text-sm">{error}</p> <p class="text-primary text-sm">{error}</p>