From 16f904d25b1c9717011a596dc6658cee8b11ed26 Mon Sep 17 00:00:00 2001 From: Shreekar Date: Tue, 9 Jun 2026 18:11:46 -0500 Subject: [PATCH 1/5] Start issue 58 math renderer rework From f2bfccba3443d75cea7190401ca30ca5d55c6de8 Mon Sep 17 00:00:00 2001 From: Shreekar Date: Tue, 9 Jun 2026 18:33:55 -0500 Subject: [PATCH 2/5] Normalize user math input to LaTeX --- app/flashcards/flashcards-client.test.tsx | 22 +-- app/flashcards/flashcards-client.tsx | 84 +++++----- app/quizzes/quizzes-client.test.tsx | 48 +++++- app/quizzes/quizzes-client.tsx | 145 +++++++++--------- components/math/latex-markdown.tsx | 67 ++++++++ .../note-editor/math-block-tool.test.ts | 12 ++ components/note-editor/math-block-tool.ts | 7 +- components/note-editor/note-editor.tsx | 3 +- lib/math/latex.test.ts | 20 +++ lib/math/latex.ts | 110 +++++++++++++ lib/notes/parse-markdown.test.ts | 25 +++ lib/notes/parse-markdown.ts | 7 +- 12 files changed, 411 insertions(+), 139 deletions(-) create mode 100644 components/math/latex-markdown.tsx create mode 100644 lib/math/latex.test.ts create mode 100644 lib/math/latex.ts create mode 100644 lib/notes/parse-markdown.test.ts diff --git a/app/flashcards/flashcards-client.test.tsx b/app/flashcards/flashcards-client.test.tsx index 4e7e271..036cdaa 100644 --- a/app/flashcards/flashcards-client.test.tsx +++ b/app/flashcards/flashcards-client.test.tsx @@ -77,11 +77,9 @@ describe("FlashcardsClient", () => { it("keeps My Flashcards focused on saved deck management", () => { render(); - expect(screen.getByRole("heading", { name: "My Flashcards" })).toBeInTheDocument(); - expect(screen.getByRole("heading", { name: "Biology deck" })).toBeInTheDocument(); - expect(screen.getByRole("button", { name: "Review" })).toBeInTheDocument(); - expect(screen.getByRole("button", { name: /Edit/ })).toBeInTheDocument(); - expect(screen.getByRole("button", { name: /Delete/ })).toBeInTheDocument(); + expect(screen.getByText("1 decks")).toBeInTheDocument(); + expect(screen.getByText("Biology deck")).toBeInTheDocument(); + expect(screen.getByRole("button", { name: /Open actions for Biology deck/ })).toBeInTheDocument(); expect(screen.queryByRole("checkbox", { name: /Lecture One/ })).not.toBeInTheDocument(); }); @@ -131,7 +129,7 @@ describe("FlashcardsClient", () => { await user.clear(screen.getByLabelText("Deck name")); await user.type(screen.getByLabelText("Deck name"), "Edited deck"); await user.clear(screen.getByLabelText("Front")); - await user.type(screen.getByLabelText("Front"), "Edited front"); + await user.type(screen.getByLabelText("Front"), "Edited front $√(x²)$"); await user.type(screen.getByLabelText("Tags"), "core"); await user.click(screen.getByRole("button", { name: /Save deck/ })); @@ -148,7 +146,7 @@ describe("FlashcardsClient", () => { title: "Edited deck", cards: [ { - front: "Edited front", + front: "Edited front $\\sqrt{x^2}$", back: "Generated back", tags: ["core"], }, @@ -175,7 +173,8 @@ describe("FlashcardsClient", () => { render(); - await user.click(screen.getByRole("button", { name: /Edit/ })); + await user.click(screen.getByRole("button", { name: /Open actions for Biology deck/ })); + await user.click(screen.getByRole("button", { name: "Edit" })); await user.clear(screen.getByLabelText("Deck name")); await user.type(screen.getByLabelText("Deck name"), "Renamed deck"); await user.clear(screen.getByLabelText("Back")); @@ -213,12 +212,13 @@ describe("FlashcardsClient", () => { render(); - await user.click(screen.getByRole("button", { name: /Delete/ })); + await user.click(screen.getByRole("button", { name: /Open actions for Biology deck/ })); + await user.click(screen.getByRole("button", { name: "Delete" })); await waitFor(() => { - expect(screen.queryByRole("heading", { name: "Biology deck" })).not.toBeInTheDocument(); + expect(screen.queryByText("Biology deck")).not.toBeInTheDocument(); }); - expect(screen.getByText("No flashcard decks")).toBeInTheDocument(); + expect(screen.getByText("No saved decks")).toBeInTheDocument(); expect(fetch).toHaveBeenCalledWith("/api/flashcards?deckId=deck-1", { method: "DELETE" }); }); }); diff --git a/app/flashcards/flashcards-client.tsx b/app/flashcards/flashcards-client.tsx index 1546dc8..ccc4f8a 100644 --- a/app/flashcards/flashcards-client.tsx +++ b/app/flashcards/flashcards-client.tsx @@ -10,7 +10,6 @@ import { Edit3, Loader2, MoreHorizontal, - Play, Plus, RotateCcw, Save, @@ -18,10 +17,7 @@ import { X, } from "lucide-react"; import { toast } from "sonner"; -import ReactMarkdown from "react-markdown"; -import rehypeKatex from "rehype-katex"; -import remarkGfm from "remark-gfm"; -import remarkMath from "remark-math"; +import { LatexMarkdown } from "@/components/math/latex-markdown"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; import { @@ -32,6 +28,7 @@ import { CardTitle, } from "@/components/ui/card"; import { Input, Textarea } from "@/components/ui/input"; +import { normalizeTextMathToLatex } from "@/lib/math/latex"; import { cx } from "@/lib/utils"; import type { FlashcardDeck, FlashcardItem } from "@/lib/flashcards/types"; @@ -89,6 +86,21 @@ function cloneDeck(deck: FlashcardDeck): FlashcardDeck { }; } +function normalizeCardMath(card: FlashcardItem): FlashcardItem { + return { + ...card, + front: normalizeTextMathToLatex(card.front), + back: normalizeTextMathToLatex(card.back), + }; +} + +function normalizeDeckMath(deck: FlashcardDeck): FlashcardDeck { + return { + ...deck, + cards: deck.cards.map(normalizeCardMath), + }; +} + function withCardCount(deck: FlashcardDeck): FlashcardDeck { return { ...deck, @@ -112,39 +124,6 @@ function parseTags(value: string) { .slice(0, 8); } -function MarkdownText({ - markdown, - className, -}: { - markdown: string; - className?: string; -}) { - return ( -
-

{children}

, - ul: ({ children }) => ( -
    {children}
- ), - ol: ({ children }) => ( -
    {children}
- ), - code: ({ children }) => ( - - {children} - - ), - }} - > - {markdown} -
-
- ); -} - function StatPill({ children }: { children: ReactNode }) { return ( @@ -252,6 +231,11 @@ function DeckEditor({ onChange={(event) => updateCard(index, { front: event.target.value }) } + onBlur={(event) => + updateCard(index, { + front: normalizeTextMathToLatex(event.target.value), + }) + } /> @@ -409,6 +398,7 @@ export function FlashcardsClient({ return; } + const normalizedDeck = normalizeDeckMath(draftDeck); setIsSaving(true); try { const payload = await readJsonResponse<{ deck: FlashcardDeck }>( @@ -417,9 +407,9 @@ export function FlashcardsClient({ headers: { "Content-Type": "application/json" }, body: JSON.stringify({ mode: "save", - title: draftDeck.title, - sourceNoteIds: draftDeck.sourceNoteIds, - cards: draftDeck.cards, + title: normalizedDeck.title, + sourceNoteIds: normalizedDeck.sourceNoteIds, + cards: normalizedDeck.cards, }), }), ); @@ -444,6 +434,7 @@ export function FlashcardsClient({ return; } + const normalizedDeck = normalizeDeckMath(editingDeck); setIsSaving(true); try { const payload = await readJsonResponse<{ deck: FlashcardDeck }>( @@ -451,10 +442,10 @@ export function FlashcardsClient({ method: "PATCH", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ - deckId: editingDeck.id, - title: editingDeck.title, - sourceNoteIds: editingDeck.sourceNoteIds, - cards: editingDeck.cards, + deckId: normalizedDeck.id, + title: normalizedDeck.title, + sourceNoteIds: normalizedDeck.sourceNoteIds, + cards: normalizedDeck.cards, }), }), ); @@ -591,6 +582,7 @@ export function FlashcardsClient({ +
diff --git a/lib/notes/markdown.test.ts b/lib/notes/markdown.test.ts index 838786f..18e5e7f 100644 --- a/lib/notes/markdown.test.ts +++ b/lib/notes/markdown.test.ts @@ -170,4 +170,32 @@ describe("serializeNoteDocumentToMarkdown", () => { "Second block moved first\n\nFirst block moved second", ); }); + + it("serializes inline math blocks with adjacent text as one inline sentence", () => { + const document: NoteDocument = { + time: 1, + blocks: [ + { + type: "paragraph", + data: { + text: "Use", + }, + }, + { + type: "inlineMath", + data: { + latex: "\\sqrt{x^2}", + }, + }, + { + type: "paragraph", + data: { + text: "here.", + }, + }, + ], + }; + + expect(serializeNoteDocumentToMarkdown(document)).toBe("Use $\\sqrt{x^2}$ here."); + }); }); diff --git a/lib/notes/markdown.ts b/lib/notes/markdown.ts index 08bfa80..34e05dc 100644 --- a/lib/notes/markdown.ts +++ b/lib/notes/markdown.ts @@ -134,16 +134,45 @@ function serializeBlock(block: NoteBlock) { } case "math": return `$$\n${block.data.latex}\n$$`; + case "inlineMath": + return `$${block.data.latex}$`; default: return ""; } } +function getBlockSeparator(previous: NoteBlock | undefined, current: NoteBlock) { + if ( + previous && + (previous.type === "inlineMath" || current.type === "inlineMath") && + (previous.type === "paragraph" || previous.type === "inlineMath") && + (current.type === "paragraph" || current.type === "inlineMath") + ) { + return " "; + } + + return "\n\n"; +} + export function serializeNoteDocumentToMarkdown(document: NoteDocument) { - return document.blocks - .map((block) => serializeBlock(block)) - .filter((block) => block.length > 0) - .join("\n\n"); + const sections: string[] = []; + let previousSerializedBlock: NoteBlock | undefined; + + for (const block of document.blocks) { + const serialized = serializeBlock(block); + if (serialized.length === 0) { + continue; + } + + if (sections.length > 0) { + sections.push(getBlockSeparator(previousSerializedBlock, block)); + } + + sections.push(serialized); + previousSerializedBlock = block; + } + + return sections.join(""); } export function createNoteContent(document: NoteDocument): NoteContent { diff --git a/lib/notes/math-regions.test.ts b/lib/notes/math-regions.test.ts index b1fe856..611bf49 100644 --- a/lib/notes/math-regions.test.ts +++ b/lib/notes/math-regions.test.ts @@ -3,7 +3,7 @@ import { normalizeNoteLatexRegions } from "@/lib/notes/math-regions"; import type { NoteDocument } from "@/lib/notes/types"; describe("normalizeNoteLatexRegions", () => { - it("keeps single-dollar math inline inside paragraph blocks", () => { + it("converts single-dollar math to inline math blocks", () => { const document: NoteDocument = { time: 1, blocks: [ @@ -20,13 +20,25 @@ describe("normalizeNoteLatexRegions", () => { { type: "paragraph", data: { - text: 'Use $x^2 + y^2 = z^2$ for the distance relation.', + text: "Use", + }, + }, + { + type: "inlineMath", + data: { + latex: "x^2 + y^2 = z^2", + }, + }, + { + type: "paragraph", + data: { + text: "for the distance relation.", }, }, ]); }); - it("keeps imported inline math spans inline", () => { + it("converts imported inline math spans to inline math blocks", () => { const document: NoteDocument = { time: 1, blocks: [ @@ -43,7 +55,13 @@ describe("normalizeNoteLatexRegions", () => { { type: "paragraph", data: { - text: 'Area $\\pi r^2$', + text: "Area", + }, + }, + { + type: "inlineMath", + data: { + latex: "\\pi r^2", }, }, ]); diff --git a/lib/notes/math-regions.ts b/lib/notes/math-regions.ts index acd1ef0..52516eb 100644 --- a/lib/notes/math-regions.ts +++ b/lib/notes/math-regions.ts @@ -9,6 +9,10 @@ type RegionSegment = | { type: "math"; latex: string; + } + | { + type: "inlineMath"; + latex: string; }; type RichTextNoteBlock = Extract< NoteBlock, @@ -31,19 +35,6 @@ function decodeHtml(value: string) { .replace(/'/g, "'"); } -function escapeHtmlAttr(value: string) { - return value - .replace(/&/g, "&") - .replace(/"/g, """) - .replace(//g, ">"); -} - -function inlineMathSpan(latex: string) { - const escaped = escapeHtmlAttr(latex); - return `$${latex}$`; -} - function hasInlineMathSpan(value: string) { INLINE_MATH_SPAN_RE.lastIndex = 0; return INLINE_MATH_SPAN_RE.test(value); @@ -117,7 +108,9 @@ function splitLatexRegions(value: string): RegionSegment[] { const latex = normalizeLatex(match[1] ?? match[2] ?? match[3] ?? match[4] ?? ""); if (latex) { if (isInline) { - pendingText += inlineMathSpan(latex); + pushTextSegment(segments, pendingText); + pendingText = ""; + segments.push({ type: "inlineMath", latex }); } else { pushTextSegment(segments, pendingText); pendingText = ""; @@ -151,6 +144,15 @@ function mathBlock(latex: string): NoteBlock { }; } +function inlineMathBlock(latex: string): NoteBlock { + return { + type: "inlineMath", + data: { + latex, + }, + }; +} + function richTextBlockLike(block: RichTextNoteBlock, text: string): NoteBlock { if (block.type === "header") { return { @@ -211,9 +213,17 @@ function convertRichTextBlock(block: NoteBlock): NoteBlock[] { return text === sourceText ? [block] : [richTextBlockLike(block, text)]; } - return segments.map((segment) => - segment.type === "math" ? mathBlock(segment.latex) : paragraphBlock(segment.value), - ); + return segments.map((segment) => { + if (segment.type === "math") { + return mathBlock(segment.latex); + } + + if (segment.type === "inlineMath") { + return inlineMathBlock(segment.latex); + } + + return paragraphBlock(segment.value); + }); } export function normalizeNoteLatexRegions(document: NoteDocument): NoteDocument { diff --git a/lib/notes/parse-markdown.test.ts b/lib/notes/parse-markdown.test.ts index 9113167..99c3f71 100644 --- a/lib/notes/parse-markdown.test.ts +++ b/lib/notes/parse-markdown.test.ts @@ -11,7 +11,19 @@ describe("parseMarkdownToNoteDocument", () => { { type: "paragraph", data: { - text: 'Use $\\sqrt{x^2}$ here.', + text: "Use", + }, + }, + { + type: "inlineMath", + data: { + latex: "\\sqrt{x^2}", + }, + }, + { + type: "paragraph", + data: { + text: "here.", }, }, { diff --git a/lib/notes/parse-markdown.ts b/lib/notes/parse-markdown.ts index a2a0d07..5524155 100644 --- a/lib/notes/parse-markdown.ts +++ b/lib/notes/parse-markdown.ts @@ -9,7 +9,7 @@ * - Block math ($$…$$) * - Blockquotes (>) * - Ordered / unordered / checklist lists - * - Inline math ($…$) inside paragraph text + * - Inline math ($…$) as inline math blocks * - Paragraphs (everything else) */ @@ -17,54 +17,6 @@ import type { NoteBlock, NoteDocument, NoteListBlockData, NoteListItem } from "@ import { normalizeLatex } from "@/lib/math/latex"; import { normalizeNoteLatexRegions } from "@/lib/notes/math-regions"; -// --------------------------------------------------------------------------- -// Helpers -// --------------------------------------------------------------------------- - -function escapeHtmlAttr(value: string) { - return value - .replace(/&/g, "&") - .replace(/"/g, """) - .replace(//g, ">"); -} - -/** - * Detect and replace inline math ($…$) within a single line of text. - * - * Heuristic: we match $content$ where content is non-empty and contains at - * least one LaTeX-typical character (backslash, caret, underscore, or braces) - * OR is a short single-token that doesn't look like a shell / currency value. - * This avoids false-positives on "$HOME", "$5.99", etc. - * - * The result is a element so - * the serializer and renderer can round-trip it cleanly. - */ -function processInlineMath(line: string): string { - // Avoid matching $$ (block math uses doubled dollar signs) - // Pattern: $$ — negative lookaround for $ - const re = /(? { - const trimmed = content.trim(); - if (!trimmed) return _match; - - // Accept as inline math if content contains any LaTeX-specific char, or - // is a multi-character expression that can't be a bare shell identifier. - const hasLatexChar = /[\\^_{}]/.test(trimmed); - const isBareShellId = /^[A-Za-z_][A-Za-z0-9_]*$/.test(trimmed); - const isCurrency = /^\d/.test(trimmed); - - if (!hasLatexChar && (isBareShellId || isCurrency)) { - return _match; // leave untouched - } - - const latex = normalizeLatex(trimmed); - - return `$${latex}$`; - }); -} - function isListLine(line: string) { return /^[-*+]\s/.test(line) || /^\d+\.\s/.test(line); } @@ -149,7 +101,7 @@ export function parseMarkdownToNoteDocument(markdown: string): NoteDocument { blocks.push({ type: "quote", data: { - text: quoteLines.map(processInlineMath).join("
"), + text: quoteLines.join("
"), caption: "", alignment: "left", }, @@ -174,10 +126,9 @@ export function parseMarkdownToNoteDocument(markdown: string): NoteDocument { const olMatch = itemLine.match(/^\d+\.\s+(.*)/); const ulMatch = itemLine.match(/^[-*+]\s+(.*)/); const rawContent = (clMatch?.[2] ?? olMatch?.[1] ?? ulMatch?.[1] ?? "").trim(); - const content = processInlineMath(rawContent); const checked = clMatch ? clMatch[1] !== " " : false; items.push({ - content, + content: rawContent, meta: style === "checklist" ? { checked } : {}, items: [], }); @@ -203,7 +154,7 @@ export function parseMarkdownToNoteDocument(markdown: string): NoteDocument { i++; } if (paragraphLines.length > 0) { - const text = paragraphLines.map(processInlineMath).join("
"); + const text = paragraphLines.join("
"); blocks.push({ type: "paragraph", data: { text } }); } } diff --git a/lib/notes/types.ts b/lib/notes/types.ts index 3471e39..0927a59 100644 --- a/lib/notes/types.ts +++ b/lib/notes/types.ts @@ -9,7 +9,8 @@ export type NoteBlockType = | "quote" | "code" | "image" - | "math"; + | "math" + | "inlineMath"; export type NoteParagraphBlockData = { text: string; @@ -68,6 +69,8 @@ export type NoteMathBlockData = { latex: string; }; +export type NoteInlineMathBlockData = NoteMathBlockData; + export type NoteBlock = | OutputBlockData<"paragraph", NoteParagraphBlockData> | OutputBlockData<"header", NoteHeaderBlockData> @@ -75,7 +78,8 @@ export type NoteBlock = | OutputBlockData<"quote", NoteQuoteBlockData> | OutputBlockData<"code", NoteCodeBlockData> | OutputBlockData<"image", NoteImageBlockData> - | OutputBlockData<"math", NoteMathBlockData>; + | OutputBlockData<"math", NoteMathBlockData> + | OutputBlockData<"inlineMath", NoteInlineMathBlockData>; export type NoteDocument = Omit & { blocks: NoteBlock[]; @@ -185,6 +189,15 @@ const mathBlockSchema = z.object({ tunes: z.record(z.string(), z.unknown()).optional(), }); +const inlineMathBlockSchema = z.object({ + id: z.string().optional(), + type: z.literal("inlineMath"), + data: z.object({ + latex: z.string(), + }), + tunes: z.record(z.string(), z.unknown()).optional(), +}); + export const NoteBlockSchema = z.discriminatedUnion("type", [ paragraphBlockSchema, headerBlockSchema, @@ -193,6 +206,7 @@ export const NoteBlockSchema = z.discriminatedUnion("type", [ codeBlockSchema, imageBlockSchema, mathBlockSchema, + inlineMathBlockSchema, ]); export const NoteDocumentSchema: z.ZodType = z