From bac91562ec0f3c8bb696cc260f5d3b87d4d37460 Mon Sep 17 00:00:00 2001 From: liyunze <50455574+liyunze-coding@users.noreply.github.com> Date: Fri, 4 Sep 2026 21:44:01 +0800 Subject: [PATCH] added link bubbles to redirect to other parts of the site --- src/lib/components/Markdown.svelte | 32 +++++++- src/lib/components/VoteStack.svelte | 25 ++++-- src/lib/markdown.spec.ts | 57 +++++++++++++- src/lib/markdown.ts | 73 +++++++++++++++++- src/lib/paths.ts | 102 +++++++++++++++++++++++++ src/lib/server/api.ts | 33 ++++++++ src/routes/[code]/+page.svelte | 5 +- src/routes/[code]/[id]/+page.svelte | 45 +++++++---- src/routes/layout.css | 31 ++++++++ src/routes/notes/[id]/+page.svelte | 38 ++++++--- src/routes/questions/[id]/+page.svelte | 38 ++++++--- src/routes/topics/[slug]/+page.svelte | 37 +++++---- src/routes/units/[code]/+page.svelte | 49 +++++++----- src/routes/users/[id]/+page.svelte | 21 ++++- 14 files changed, 497 insertions(+), 89 deletions(-) diff --git a/src/lib/components/Markdown.svelte b/src/lib/components/Markdown.svelte index abf22e7..3ed1661 100644 --- a/src/lib/components/Markdown.svelte +++ b/src/lib/components/Markdown.svelte @@ -1,8 +1,36 @@ -
{@html renderMarkdown(content)}
+
{@html html}
diff --git a/src/lib/components/VoteStack.svelte b/src/lib/components/VoteStack.svelte index a6b8b8b..f14b064 100644 --- a/src/lib/components/VoteStack.svelte +++ b/src/lib/components/VoteStack.svelte @@ -2,7 +2,6 @@ import { query, mutation } from "$lib/api"; import { getToken } from "$lib/stores/auth"; import { goto } from "$app/navigation"; - import { onMount } from "svelte"; let { count = 0, @@ -19,14 +18,26 @@ let busy = $state(false); const voteCount = $derived(localCount ?? count); - onMount(async () => { + $effect(() => { + const type = targetType; + const id = targetId; + localCount = null; + userVote = 0; const token = getToken(); if (!token) return; - try { - userVote = (await query("votes:getMyVote", { token, targetType, targetId })) ?? 0; - } catch { - /* ignore */ - } + + let cancelled = false; + void query("votes:getMyVote", { token, targetType: type, targetId: id }) + .then((vote) => { + if (!cancelled) userVote = vote ?? 0; + }) + .catch(() => { + /* ignore */ + }); + + return () => { + cancelled = true; + }; }); async function vote(value: 1 | -1, e: MouseEvent) { diff --git a/src/lib/markdown.spec.ts b/src/lib/markdown.spec.ts index a1ce06c..2984a47 100644 --- a/src/lib/markdown.spec.ts +++ b/src/lib/markdown.spec.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import { previewMarkdown, renderMarkdown } from "./markdown"; +import { collectInternalPostIds, previewMarkdown, renderMarkdown } from "./markdown"; describe("renderMarkdown", () => { it("renders inline and display LaTeX with KaTeX", () => { @@ -34,6 +34,61 @@ describe("renderMarkdown", () => { expect(rendered).toContain("&"); expect(rendered).not.toContain("
"); }); + + it("styles same-origin and relative links as internal bubbles", () => { + const origin = "https://notebook.example"; + const relative = renderMarkdown("[details](/SIT223/abc)", { origin }); + const absolute = renderMarkdown(`[details](${origin}/notes/abc)`, { origin }); + const external = renderMarkdown("[site](https://example.com/notes/abc)", { origin }); + + expect(relative).toContain('class="md-internal"'); + expect(relative).toContain('href="/SIT223/abc"'); + expect(absolute).toContain('class="md-internal"'); + expect(absolute).toContain('href="/notes/abc"'); + expect(external).not.toContain("md-internal"); + expect(external).toContain('href="https://example.com/notes/abc"'); + }); + + it("uses a short label for autolinked internal urls", () => { + const origin = "https://notebook.example"; + const rendered = renderMarkdown(`${origin}/questions/abc`, { origin }); + + expect(rendered).toContain('class="md-internal"'); + expect(rendered).toContain("Question"); + expect(rendered).not.toContain(`${origin}/questions/abc<`); + }); + + it("labels autolinked unit posts with the post title when provided", () => { + const rendered = renderMarkdown("[/SIT223/abc-id](/SIT223/abc-id)", { + titles: { "abc-id": "Week 3 notes" }, + }); + + expect(rendered).toContain("Week 3 notes"); + expect(rendered).not.toContain("SIT223 post"); + }); + + it("falls back to a unit post label without a title", () => { + expect(renderMarkdown("[/SIT223/abc-id](/SIT223/abc-id)")).toContain("SIT223 post"); + }); + + it("keeps custom link text on internal bubbles", () => { + const rendered = renderMarkdown("[Week 3 notes](/notes/abc)", { + titles: { abc: "Actual title" }, + }); + expect(rendered).toContain("Week 3 notes"); + expect(rendered).toContain('class="md-internal"'); + expect(rendered).not.toContain("Actual title"); + }); + + it("collects autolinked post ids for title lookup", () => { + const origin = "https://notebook.example"; + expect( + collectInternalPostIds( + `${origin}/SIT223/unit-post\n${origin}/notes/note-id\n[custom](/questions/q-id)`, + origin, + ), + ).toEqual(["unit-post", "note-id"]); + }); }); describe("previewMarkdown", () => { diff --git a/src/lib/markdown.ts b/src/lib/markdown.ts index a0b319f..9f14070 100644 --- a/src/lib/markdown.ts +++ b/src/lib/markdown.ts @@ -2,6 +2,7 @@ import MarkdownIt from "markdown-it"; import texmath from "markdown-it-texmath"; import katex from "katex"; import hljs from "highlight.js/lib/common"; +import { appPathLabel, isAutolinkText, postIdFromAppPath, toAppPath } from "$lib/paths"; const md = new MarkdownIt({ html: false, @@ -19,6 +20,8 @@ md.use(texmath, { }, }); +md.use(internalLinksPlugin); + function highlightCode(source: string, lang: string): string { const language = lang.trim().toLowerCase(); const escapedLang = language ? md.utils.escapeHtml(language) : ""; @@ -43,8 +46,74 @@ const COPY_BUTTON = ``; -export function renderMarkdown(source: string): string { - return md.render(source ?? ""); +function internalLinksPlugin(markdown: typeof md) { + markdown.core.ruler.after("inline", "internal_links", (state) => { + const origin = typeof state.env?.origin === "string" ? state.env.origin : undefined; + const titles = + state.env?.titles && typeof state.env.titles === "object" + ? (state.env.titles as Record) + : undefined; + const collected = + state.env?.postIds instanceof Set ? (state.env.postIds as Set) : undefined; + + for (const block of state.tokens) { + if (block.type !== "inline" || !block.children) continue; + const children = block.children; + + for (let i = 0; i < children.length; i++) { + const token = children[i]; + if (token.type !== "link_open") continue; + + const href = String(token.attrGet("href") ?? ""); + const appPath = toAppPath(href, origin); + if (!appPath) continue; + + const close = findLinkClose(children, i); + if (close === -1) continue; + + const inner = children.slice(i + 1, close); + if (inner.some((child) => child.type === "image")) continue; + + token.attrSet("href", appPath); + token.attrJoin("class", "md-internal"); + + if ( + inner.length === 1 && + inner[0].type === "text" && + isAutolinkText(String(inner[0].content), href, appPath) + ) { + const postId = postIdFromAppPath(appPath); + if (postId) collected?.add(postId); + inner[0].content = appPathLabel(appPath, titles); + } + } + } + }); +} + +function findLinkClose(children: { type: string }[], openIdx: number): number { + let depth = 1; + for (let i = openIdx + 1; i < children.length; i++) { + if (children[i].type === "link_open") depth += 1; + if (children[i].type === "link_close") { + depth -= 1; + if (depth === 0) return i; + } + } + return -1; +} + +export function renderMarkdown( + source: string, + options?: { origin?: string; titles?: Record }, +): string { + return md.render(source ?? "", { origin: options?.origin, titles: options?.titles }); +} + +export function collectInternalPostIds(source: string, origin?: string): string[] { + const postIds = new Set(); + md.parse(source ?? "", { origin, postIds }); + return [...postIds]; } export function previewMarkdown(source: string, maxLength = 180): string { diff --git a/src/lib/paths.ts b/src/lib/paths.ts index 965f95e..6fbdc75 100644 --- a/src/lib/paths.ts +++ b/src/lib/paths.ts @@ -5,3 +5,105 @@ export function unitPath(code: string): string { export function postPath(code: string, postId: string): string { return `${unitPath(code)}/${encodeURIComponent(postId)}`; } + +const SKIP_APP_PREFIXES = ["/uploads/", "/api/"]; + +const APP_SECTIONS = new Set([ + "notes", + "questions", + "units", + "users", + "topics", + "search", + "account", + "admin", + "auth", + "post", +]); + +export function toAppPath(href: string, origin?: string): string | null { + const trimmed = href.trim(); + if (!trimmed || trimmed.startsWith("#") || /^(?:mailto|javascript|data|blob):/i.test(trimmed)) { + return null; + } + + let url: URL; + try { + if (origin) { + url = new URL(trimmed, origin); + if (url.origin !== new URL(origin).origin) return null; + } else if (trimmed.startsWith("/")) { + url = new URL(trimmed, "https://internal.invalid"); + } else { + return null; + } + } catch { + return null; + } + + if (SKIP_APP_PREFIXES.some((prefix) => url.pathname.startsWith(prefix))) return null; + + return `${url.pathname}${url.search}${url.hash}`; +} + +export function postIdFromAppPath(appPath: string): string | null { + const url = new URL(appPath, "https://internal.invalid"); + const parts = url.pathname.split("/").filter(Boolean).map(decodeURIComponent); + if (parts.length !== 2) return null; + + const [head, rest] = parts; + if (!rest) return null; + if (head === "notes" || head === "questions") return rest; + if (APP_SECTIONS.has(head)) return null; + return rest; +} + +export function appPathLabel(appPath: string, titles?: Record): string { + const postId = postIdFromAppPath(appPath); + const title = postId ? titles?.[postId]?.trim() : undefined; + if (title) return title; + + const url = new URL(appPath, "https://internal.invalid"); + const parts = url.pathname.split("/").filter(Boolean).map(decodeURIComponent); + if (parts.length === 0) return "Home"; + + const [head, rest] = parts; + switch (head) { + case "notes": + return rest ? "Note" : "Notes"; + case "questions": + return rest ? "Question" : "Questions"; + case "units": + return rest ? rest.toUpperCase() : "Units"; + case "users": + return "Profile"; + case "topics": + return rest ?? "Topics"; + case "search": + return "Search"; + case "account": + return "Account"; + case "admin": + return "Admin"; + case "auth": + return "Sign in"; + case "post": + return rest === "question" ? "New question" : "New note"; + default: + return rest ? `${head} post` : head; + } +} + +export function isAutolinkText(text: string, href: string, appPath: string): boolean { + const value = text.trim(); + if (!value) return false; + const candidates = [href, appPath]; + try { + candidates.push(decodeURI(href), decodeURI(appPath)); + } catch { + /* ignore malformed percent-encoding */ + } + return candidates.some( + (candidate) => candidate === value || candidate.replace(/\/$/, "") === value, + ); +} diff --git a/src/lib/server/api.ts b/src/lib/server/api.ts index 3e08379..423b7f7 100644 --- a/src/lib/server/api.ts +++ b/src/lib/server/api.ts @@ -906,6 +906,38 @@ function votesGetMyVote( // ---- details ---- +const MAX_POST_TITLES = 50; + +function postsGetTitles(db: Db, args: { ids?: string[] }) { + const ids = [ + ...new Set( + (Array.isArray(args.ids) ? args.ids : []) + .filter((id): id is string => typeof id === "string") + .map((id) => id.trim()) + .filter(Boolean), + ), + ].slice(0, MAX_POST_TITLES); + + if (ids.length === 0) return {}; + + const placeholders = ids.map(() => "?").join(","); + const titles: Record = {}; + + for (const row of db + .prepare(`SELECT id, title FROM notes WHERE id IN (${placeholders})`) + .all(...ids) as { id: string; title: string }[]) { + titles[row.id] = row.title; + } + + for (const row of db + .prepare(`SELECT id, title FROM questions WHERE id IN (${placeholders})`) + .all(...ids) as { id: string; title: string }[]) { + if (titles[row.id] === undefined) titles[row.id] = row.title; + } + + return titles; +} + function getNoteWithDetails(db: Db, args: { id: string }) { const note = db.prepare(`SELECT ${NOTE_COLUMNS} FROM notes WHERE id = ?`).get(args.id) as | Record @@ -1311,6 +1343,7 @@ const handlers: Record = { "comments:update": commentsUpdate, "votes:cast": votesCast, "votes:getMyVote": votesGetMyVote, + "posts:getTitles": postsGetTitles, "details:getNoteWithDetails": getNoteWithDetails, "details:getQuestionWithDetails": getQuestionWithDetails, "admin:getState": adminGetState, diff --git a/src/routes/[code]/+page.svelte b/src/routes/[code]/+page.svelte index 9add293..6c5a778 100644 --- a/src/routes/[code]/+page.svelte +++ b/src/routes/[code]/+page.svelte @@ -1,5 +1,8 @@ - +{#key page.params.code} + +{/key} diff --git a/src/routes/[code]/[id]/+page.svelte b/src/routes/[code]/[id]/+page.svelte index 9a59330..84e4cf0 100644 --- a/src/routes/[code]/[id]/+page.svelte +++ b/src/routes/[code]/[id]/+page.svelte @@ -1,28 +1,37 @@ @@ -31,9 +40,13 @@

Loading

{:else if postType === "note"} - + {#key page.params.id} + + {/key} {:else if postType === "question"} - + {#key page.params.id} + + {/key} {:else}

Post not found

diff --git a/src/routes/layout.css b/src/routes/layout.css index 8b16f4d..7ed79fa 100644 --- a/src/routes/layout.css +++ b/src/routes/layout.css @@ -231,6 +231,37 @@ button { color: var(--color-secondary-dark); } +.markdown a.md-internal { + display: inline; + color: var(--color-secondary); + background: color-mix(in srgb, var(--color-secondary) 16%, transparent); + text-decoration: none; + border-radius: 6px; + padding: 0.12em 0.45em; + font-weight: 500; + box-decoration-break: clone; + -webkit-box-decoration-break: clone; +} + +.markdown a.md-internal::before { + content: ""; + display: inline-block; + width: 0.8em; + height: 0.8em; + margin-right: 0.28em; + vertical-align: -0.08em; + background-color: currentColor; + mask: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2.4' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M10 13a5 5 0 0 0 7.54.54l1.07-1.07a5 5 0 0 0-7.07-7.07L10.4 6.54'/%3E%3Cpath d='M14 11a5 5 0 0 0-7.54-.54L5.4 11.54a5 5 0 0 0 7.07 7.07l1.13-1.14'/%3E%3C/svg%3E") + center / contain no-repeat; + -webkit-mask: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2.4' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M10 13a5 5 0 0 0 7.54.54l1.07-1.07a5 5 0 0 0-7.07-7.07L10.4 6.54'/%3E%3Cpath d='M14 11a5 5 0 0 0-7.54-.54L5.4 11.54a5 5 0 0 0 7.07 7.07l1.13-1.14'/%3E%3C/svg%3E") + center / contain no-repeat; +} + +.markdown a.md-internal:hover { + color: var(--color-secondary-dark); + background: color-mix(in srgb, var(--color-secondary) 26%, transparent); +} + .markdown code { background: var(--color-rule); color: var(--color-ink); diff --git a/src/routes/notes/[id]/+page.svelte b/src/routes/notes/[id]/+page.svelte index fa8e189..9699890 100644 --- a/src/routes/notes/[id]/+page.svelte +++ b/src/routes/notes/[id]/+page.svelte @@ -2,7 +2,6 @@ import { query, mutation } from "$lib/api"; import { isAuthenticated, getToken, currentUser } from "$lib/stores/auth"; import { page } from "$app/state"; - import { onMount } from "svelte"; import { get } from "svelte/store"; import VoteStack from "$lib/components/VoteStack.svelte"; import Markdown from "$lib/components/Markdown.svelte"; @@ -29,18 +28,33 @@ let editError = $state(""); let editLoading = $state(false); - onMount(async () => { + $effect(() => { const id = page.params.id; - const result = await query("details:getNoteWithDetails", { id }); - if (result) { - note = result as NoteDoc; - topic = (result as any).topic ?? null; - unit = (result as any).unit ?? null; - comments = (result as any).comments ?? []; - const cu = get(currentUser); - if (cu) isAuthor = note.authorId === cu._id; - } - loading = false; + let cancelled = false; + loading = true; + note = null; + editMode = false; + commentText = ""; + commentError = ""; + isAuthor = false; + + void (async () => { + const result = await query("details:getNoteWithDetails", { id }); + if (cancelled) return; + if (result) { + note = result as NoteDoc; + topic = (result as any).topic ?? null; + unit = (result as any).unit ?? null; + comments = (result as any).comments ?? []; + const cu = get(currentUser); + if (cu) isAuthor = note.authorId === cu._id; + } + loading = false; + })(); + + return () => { + cancelled = true; + }; }); async function reloadComments() { diff --git a/src/routes/questions/[id]/+page.svelte b/src/routes/questions/[id]/+page.svelte index c610fa1..9e51d36 100644 --- a/src/routes/questions/[id]/+page.svelte +++ b/src/routes/questions/[id]/+page.svelte @@ -2,7 +2,6 @@ import { query, mutation } from "$lib/api"; import { isAuthenticated, getToken, currentUser } from "$lib/stores/auth"; import { page } from "$app/state"; - import { onMount } from "svelte"; import { get } from "svelte/store"; import VoteStack from "$lib/components/VoteStack.svelte"; import Markdown from "$lib/components/Markdown.svelte"; @@ -28,18 +27,33 @@ let editError = $state(""); let editLoading = $state(false); - onMount(async () => { + $effect(() => { const id = page.params.id; - const result = await query("details:getQuestionWithDetails", { id }); - if (result) { - question = result as QuestionDoc; - topic = (result as any).topic ?? null; - unit = (result as any).unit ?? null; - answers = (result as any).answers ?? []; - const cu = get(currentUser); - if (cu) isAuthor = question.authorId === cu._id; - } - loading = false; + let cancelled = false; + loading = true; + question = null; + editMode = false; + answerText = ""; + answerError = ""; + isAuthor = false; + + void (async () => { + const result = await query("details:getQuestionWithDetails", { id }); + if (cancelled) return; + if (result) { + question = result as QuestionDoc; + topic = (result as any).topic ?? null; + unit = (result as any).unit ?? null; + answers = (result as any).answers ?? []; + const cu = get(currentUser); + if (cu) isAuthor = question.authorId === cu._id; + } + loading = false; + })(); + + return () => { + cancelled = true; + }; }); async function reloadAnswers() { diff --git a/src/routes/topics/[slug]/+page.svelte b/src/routes/topics/[slug]/+page.svelte index c4912a7..a059bac 100644 --- a/src/routes/topics/[slug]/+page.svelte +++ b/src/routes/topics/[slug]/+page.svelte @@ -1,7 +1,6 @@ diff --git a/src/routes/units/[code]/+page.svelte b/src/routes/units/[code]/+page.svelte index bb861b7..45dfb05 100644 --- a/src/routes/units/[code]/+page.svelte +++ b/src/routes/units/[code]/+page.svelte @@ -1,7 +1,6 @@