mirror of
https://github.com/dsec-hub/dsec-notebook.git
synced 2026-09-22 07:24:27 +00:00
Merge pull request #13 from dsec-hub/feature/LaTeX-support
added LaTeX support
This commit is contained in:
commit
49f02cd07b
10 changed files with 225 additions and 118 deletions
37
package-lock.json
generated
37
package-lock.json
generated
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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": {
|
||||
|
|
|
|||
|
|
@ -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("`")}>
|
||||
</>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="editor-tool font-serif italic"
|
||||
title="Inline equation"
|
||||
onclick={() => wrap("$")}
|
||||
>
|
||||
x²
|
||||
</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>
|
||||
|
|
|
|||
|
|
@ -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", () => {
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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
22
src/markdown-it-texmath.d.ts
vendored
Normal 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;
|
||||
}
|
||||
|
|
@ -1,4 +1,6 @@
|
|||
@import "tailwindcss";
|
||||
@import "katex/dist/katex.min.css";
|
||||
@import "markdown-it-texmath/css/texmath.css";
|
||||
|
||||
@font-face {
|
||||
font-family: "Inter";
|
||||
|
|
|
|||
|
|
@ -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,10 +117,12 @@
|
|||
/>
|
||||
</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>
|
||||
<label for="topic" class="kicker mb-2 block">Topic</label>
|
||||
<select id="topic" bind:value={selectedTopicId} class="field" required>
|
||||
<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>
|
||||
|
|
@ -163,6 +164,7 @@
|
|||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if error}
|
||||
<p class="text-primary text-sm">{error}</p>
|
||||
|
|
|
|||
|
|
@ -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,10 +119,12 @@
|
|||
/>
|
||||
</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>
|
||||
<label for="topic" class="kicker mb-2 block">Topic</label>
|
||||
<select id="topic" bind:value={selectedTopicId} class="field" required>
|
||||
<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>
|
||||
|
|
@ -163,6 +166,7 @@
|
|||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if error}
|
||||
<p class="text-primary text-sm">{error}</p>
|
||||
|
|
|
|||
Loading…
Reference in a new issue