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 @@