added LaTeX support

This commit is contained in:
liyunze 2026-09-03 09:06:44 +08:00
parent a32ece672a
commit ccadada9ce
10 changed files with 225 additions and 118 deletions

37
package-lock.json generated
View file

@ -1,14 +1,16 @@
{
"name": "dsec-notebook",
"version": "0.0.1",
"version": "1.0.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "dsec-notebook",
"version": "0.0.1",
"version": "1.0.0",
"dependencies": {
"katex": "^0.18.5",
"markdown-it": "^15.0.1",
"markdown-it-texmath": "^1.0.0",
"resend": "^6.25.0"
},
"devDependencies": {
@ -6455,6 +6457,31 @@
"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": {
"version": "4.1.5",
"resolved": "https://registry.npmjs.org/kleur/-/kleur-4.1.5.tgz",
@ -6823,6 +6850,12 @@
"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": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",

View file

@ -18,7 +18,9 @@
"lint:fix": "oxlint --fix"
},
"dependencies": {
"katex": "^0.18.5",
"markdown-it": "^15.0.1",
"markdown-it-texmath": "^1.0.0",
"resend": "^6.25.0"
},
"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) {
applyEdit((value, start, 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("`")}>
&lt;/&gt;
</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
type="button"
class="editor-tool"
@ -280,7 +310,8 @@
/>
<p class="text-faint mt-2 text-xs">
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>
{#if uploading}
<p class="text-muted mt-1 text-xs">Uploading image...</p>

View file

@ -1,5 +1,20 @@
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", () => {
it("returns readable text without markdown formatting", () => {

View file

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

View file

@ -469,9 +469,12 @@ const NOTE_COLUMNS =
function notesCreate(
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 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 now = Date.now();
db.exec("BEGIN");
@ -479,17 +482,7 @@ function notesCreate(
db.prepare(
`INSERT INTO notes (id, title, content, topicId, unitId, authorId, authorName, createdAt, updatedAt, voteCount, commentCount)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 1, 0)`,
).run(
id,
args.title,
args.content,
args.topicId,
args.unitId,
user._id,
user.name,
now,
now,
);
).run(id, args.title, args.content, topicId, unitId, user._id, user.name, now, now);
db.prepare(
"INSERT INTO votes (id, userId, targetType, targetId, value) VALUES (?, ?, 'note', ?, 1)",
).run(newId(), user._id, id);
@ -587,9 +580,12 @@ const QUESTION_COLUMNS =
function questionsCreate(
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 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 now = Date.now();
db.exec("BEGIN");
@ -597,17 +593,7 @@ function questionsCreate(
db.prepare(
`INSERT INTO questions (id, title, content, topicId, unitId, authorId, authorName, createdAt, updatedAt, voteCount, answerCount, solved)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 1, 0, 0)`,
).run(
id,
args.title,
args.content,
args.topicId,
args.unitId,
user._id,
user.name,
now,
now,
);
).run(id, args.title, args.content, topicId, unitId, user._id, user.name, now, now);
db.prepare(
"INSERT INTO votes (id, userId, targetType, targetId, value) VALUES (?, ?, 'question', ?, 1)",
).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 "katex/dist/katex.min.css";
@import "markdown-it-texmath/css/texmath.css";
@font-face {
font-family: "Inter";

View file

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

View file

@ -38,8 +38,9 @@
error = "Title and content are required";
return;
}
if (!selectedTopicId) {
error = "Please select a topic";
const hasUnit = useCustomUnit ? !!customUnit.trim() : !!selectedUnitId;
if (!selectedTopicId && !hasUnit) {
error = "Please select a topic or a unit";
return;
}
@ -51,7 +52,7 @@
loading = true;
try {
let unitId = selectedUnitId;
let unitId = useCustomUnit ? "" : selectedUnitId;
let unitCode = units.find((unit) => unit._id === unitId)?.code ?? "";
if (useCustomUnit && customUnit.trim()) {
unitCode = customUnit.trim().toUpperCase();
@ -77,7 +78,7 @@
unitId,
})) as string;
goto(postPath(unitCode, id));
goto(unitCode ? postPath(unitCode, id) : `/questions/${id}`);
} catch (err: any) {
error = err.message ?? "Failed to post question";
} finally {
@ -118,49 +119,52 @@
/>
</div>
<div class="grid grid-cols-1 gap-6 sm:grid-cols-2">
<div>
<label for="topic" class="kicker mb-2 block">Topic</label>
<select id="topic" bind:value={selectedTopicId} class="field" required>
<option value="">Select a topic...</option>
{#each topics as topic}
<option value={topic._id}>{topic.name}</option>
{/each}
</select>
</div>
<div>
<label for="unit" class="kicker mb-2 block">Unit</label>
{#if !useCustomUnit}
<select id="unit" bind:value={selectedUnitId} class="field">
<option value="">Select a unit...</option>
{#each units as unit}
<option value={unit._id}
>{unit.code}{unit.code2 ? ` / ${unit.code2}` : ""}{unit.name}</option
>
<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>
<label for="topic" class="kicker mb-2 block">Topic (optional)</label>
<select id="topic" bind:value={selectedTopicId} class="field">
<option value="">Select a topic...</option>
{#each topics as topic}
<option value={topic._id}>{topic.name}</option>
{/each}
</select>
{/if}
</div>
<button
type="button"
onclick={() => {
useCustomUnit = !useCustomUnit;
customUnit = "";
}}
class="text-secondary hover:text-secondary-dark mt-2 text-xs"
>
{useCustomUnit ? "Choose from list" : "Other unit..."}
</button>
<div>
<label for="unit" class="kicker mb-2 block">Unit</label>
{#if !useCustomUnit}
<select id="unit" bind:value={selectedUnitId} class="field">
<option value="">Select a unit...</option>
{#each units as unit}
<option value={unit._id}
>{unit.code}{unit.code2 ? ` / ${unit.code2}` : ""}{unit.name}</option
>
{/each}
</select>
{/if}
{#if useCustomUnit}
<input
type="text"
bind:value={customUnit}
placeholder="e.g., SIT384"
class="field mt-2"
/>
{/if}
<button
type="button"
onclick={() => {
useCustomUnit = !useCustomUnit;
customUnit = "";
}}
class="text-secondary hover:text-secondary-dark mt-2 text-xs"
>
{useCustomUnit ? "Choose from list" : "Other unit..."}
</button>
{#if useCustomUnit}
<input
type="text"
bind:value={customUnit}
placeholder="e.g., SIT384"
class="field mt-2"
/>
{/if}
</div>
</div>
</div>