added meta tags

This commit is contained in:
liyunze 2026-09-05 10:21:30 +08:00
parent 86e089c3e0
commit 0587abd826
16 changed files with 178 additions and 16 deletions

4
src/app.d.ts vendored
View file

@ -4,7 +4,9 @@ export {};
declare global {
namespace App {
interface PageData {}
interface PageData {
seo?: import("$lib/seo").PageSeo;
}
interface PageState {}
interface Platform {}
}

View file

@ -3,10 +3,6 @@
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta
name="description"
content="A resource hub for Deakin University students. Shared study notes, questions and answers for SIT and Mathematics units."
/>
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="default" />
<meta name="apple-mobile-web-app-title" content="Notebook" />

View file

@ -0,0 +1,14 @@
<script lang="ts">
import type { PageSeo } from "$lib/seo";
let { seo }: { seo: PageSeo | undefined } = $props();
</script>
<svelte:head>
{#if seo}
<title>{seo.title}</title>
<meta name="description" content={seo.description} />
<meta property="og:title" content={seo.title} />
<meta property="og:description" content={seo.description} />
{/if}
</svelte:head>

29
src/lib/seo.spec.ts Normal file
View file

@ -0,0 +1,29 @@
import { describe, expect, it } from "vitest";
import { postSeo, unitSeo, withSiteName } from "./seo";
describe("postSeo", () => {
it("uses the post title and markdown preview", () => {
const seo = postSeo({
title: "Week 3 notes",
content: "# Intro\n\nThese are **study** notes.",
unit: { code: "SIT232" },
});
expect(seo.title).toBe(withSiteName("Week 3 notes"));
expect(seo.description).toBe("Intro These are study notes.");
});
});
describe("unitSeo", () => {
it("includes unit codes, name, and description", () => {
const seo = unitSeo({
code: "SIT232",
code2: "SIT772",
name: "Object-Oriented Development",
description: "Classes, objects, and design.",
});
expect(seo.title).toBe(withSiteName("SIT232 / SIT772 · Object-Oriented Development"));
expect(seo.description).toBe("Classes, objects, and design.");
});
});

59
src/lib/seo.ts Normal file
View file

@ -0,0 +1,59 @@
import { previewMarkdown } from "$lib/markdown";
export const SITE_NAME = "Notebook";
export const SITE_DESCRIPTION =
"A resource hub for Deakin University students. Shared study notes, questions and answers for SIT and Mathematics units.";
export type PageSeo = {
title: string;
description: string;
};
export function withSiteName(title: string): string {
return `${title}${SITE_NAME}`;
}
function unitLabel(unit: { code: string; code2?: string }): string {
return unit.code2 ? `${unit.code} / ${unit.code2}` : unit.code;
}
export function postSeo(post: {
title: string;
content: string;
unit?: { code: string; code2?: string } | null;
}): PageSeo {
const preview = previewMarkdown(post.content);
const unit = post.unit ? unitLabel(post.unit) : "";
const description =
preview ||
(unit ? `${post.title}${unit} on DSEC Notebook.` : `${post.title} on DSEC Notebook.`);
return {
title: withSiteName(post.title),
description,
};
}
export function unitSeo(unit: {
code: string;
code2?: string;
name: string;
description?: string;
}): PageSeo {
const codes = unitLabel(unit);
const description =
unit.description?.trim() ||
`Notes and questions for ${unit.name} (${codes}) on DSEC Notebook.`;
return {
title: withSiteName(`${codes} · ${unit.name}`),
description,
};
}
export function notFoundSeo(kind: "Post" | "Unit"): PageSeo {
return {
title: withSiteName(`${kind} not found`),
description: SITE_DESCRIPTION,
};
}

25
src/lib/server/pageSeo.ts Normal file
View file

@ -0,0 +1,25 @@
import { call } from "$lib/server/api";
import { notFoundSeo, postSeo, unitSeo, type PageSeo } from "$lib/seo";
function unitMatchesCode(
unit: { code?: string; code2?: string } | null | undefined,
code: string,
): boolean {
const requested = code.toLowerCase();
return [unit?.code, unit?.code2].some((value) => value?.toLowerCase() === requested);
}
export async function loadPostSeo(id: string, requestedCode?: string): Promise<{ seo: PageSeo }> {
const note = await call("details:getNoteWithDetails", { id });
const post = note ?? (await call("details:getQuestionWithDetails", { id }));
if (!post || (requestedCode && !unitMatchesCode(post.unit, requestedCode))) {
return { seo: notFoundSeo("Post") };
}
return { seo: postSeo(post) };
}
export async function loadUnitSeo(code: string): Promise<{ seo: PageSeo }> {
const unit = await call("units:getByCode", { code });
if (!unit) return { seo: notFoundSeo("Unit") };
return { seo: unitSeo(unit) };
}

View file

@ -1,5 +1,7 @@
<script lang="ts">
import { page } from "$app/state";
import Navbar from "$lib/components/Navbar.svelte";
import { SITE_DESCRIPTION } from "$lib/seo";
import { initAuth } from "$lib/stores/auth";
import { onMount } from "svelte";
import "./layout.css";
@ -24,6 +26,9 @@
<svelte:head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
{#if !page.data.seo}
<meta name="description" content={SITE_DESCRIPTION} />
{/if}
</svelte:head>
<div class="bg-surface flex min-h-screen flex-col">

View file

@ -0,0 +1,4 @@
import { loadUnitSeo } from "$lib/server/pageSeo";
import type { PageServerLoad } from "./$types";
export const load: PageServerLoad = ({ params }) => loadUnitSeo(params.code);

View file

@ -0,0 +1,4 @@
import { loadPostSeo } from "$lib/server/pageSeo";
import type { PageServerLoad } from "./$types";
export const load: PageServerLoad = ({ params }) => loadPostSeo(params.id, params.code);

View file

@ -1,6 +1,7 @@
<script lang="ts">
import { page } from "$app/state";
import { query } from "$lib/api";
import Seo from "$lib/components/Seo.svelte";
import NotePage from "../../notes/[id]/+page.svelte";
import QuestionPage from "../../questions/[id]/+page.svelte";
@ -35,6 +36,10 @@
});
</script>
{#if loading || !postType}
<Seo seo={page.data.seo} />
{/if}
{#if loading}
<div class="page">
<p class="kicker py-16">Loading</p>

View file

@ -0,0 +1,4 @@
import { loadPostSeo } from "$lib/server/pageSeo";
import type { PageServerLoad } from "./$types";
export const load: PageServerLoad = ({ params }) => loadPostSeo(params.id);

View file

@ -7,6 +7,8 @@
import Markdown from "$lib/components/Markdown.svelte";
import MarkdownEditor from "$lib/components/MarkdownEditor.svelte";
import CommentThread from "$lib/components/CommentThread.svelte";
import Seo from "$lib/components/Seo.svelte";
import { postSeo } from "$lib/seo";
import { timeAgo } from "$lib/time";
import type { NoteDoc, CommentDoc, TopicDoc, UnitDoc } from "$lib/types";
@ -28,6 +30,8 @@
let editError = $state("");
let editLoading = $state(false);
const seo = $derived.by(() => (note ? postSeo({ ...note, unit }) : page.data.seo));
$effect(() => {
const id = page.params.id;
let cancelled = false;
@ -157,9 +161,7 @@
}
</script>
<svelte:head>
<title>{note?.title ?? "Loading..."} — Notebook</title>
</svelte:head>
<Seo {seo} />
<div class="page">
{#if loading}

View file

@ -0,0 +1,4 @@
import { loadPostSeo } from "$lib/server/pageSeo";
import type { PageServerLoad } from "./$types";
export const load: PageServerLoad = ({ params }) => loadPostSeo(params.id);

View file

@ -7,6 +7,8 @@
import Markdown from "$lib/components/Markdown.svelte";
import MarkdownEditor from "$lib/components/MarkdownEditor.svelte";
import CommentThread from "$lib/components/CommentThread.svelte";
import Seo from "$lib/components/Seo.svelte";
import { postSeo } from "$lib/seo";
import { timeAgo } from "$lib/time";
import type { QuestionDoc, CommentDoc, TopicDoc, UnitDoc } from "$lib/types";
@ -27,6 +29,8 @@
let editError = $state("");
let editLoading = $state(false);
const seo = $derived.by(() => (question ? postSeo({ ...question, unit }) : page.data.seo));
$effect(() => {
const id = page.params.id;
let cancelled = false;
@ -147,9 +151,7 @@
}
</script>
<svelte:head>
<title>{question?.title ?? "Loading..."} — Notebook</title>
</svelte:head>
<Seo {seo} />
<div class="page">
{#if loading}

View file

@ -0,0 +1,4 @@
import { loadUnitSeo } from "$lib/server/pageSeo";
import type { PageServerLoad } from "./$types";
export const load: PageServerLoad = ({ params }) => loadUnitSeo(params.code);

View file

@ -5,6 +5,8 @@
import FeedRow from "$lib/components/FeedRow.svelte";
import { postPath } from "$lib/paths";
import { getToken, initAuth, isAuthenticated } from "$lib/stores/auth";
import Seo from "$lib/components/Seo.svelte";
import { unitSeo } from "$lib/seo";
import { timeAgo } from "$lib/time";
import type { NoteDoc, QuestionDoc, UnitDoc } from "$lib/types";
@ -22,6 +24,7 @@
const isPinned = $derived(unit ? pinnedIds.includes(unit._id) : false);
const canPin = $derived(isPinned || pinnedIds.length < MAX_PINS);
const seo = $derived.by(() => (unit ? unitSeo(unit) : page.data.seo));
const visibleNotes = $derived.by(() => {
const q = searchQuery.trim().toLowerCase();
@ -98,15 +101,15 @@
}
</script>
<svelte:head>
<title>{unit?.code ?? "Unit"} — Notebook</title>
</svelte:head>
<Seo {seo} />
<div class="page">
{#if loading}
<p class="kicker py-16">Loading</p>
{:else if unit}
<p class="kicker"><a href="/" class="hover:text-primary">Home</a> · Unit</p>
<p class="kicker">
<a href="/" class="hover:text-primary">Home</a> · Unit
</p>
<div class="mt-2 flex items-center gap-2">
<h1 class="text-ink font-serif text-4xl font-medium">
{unit.code}{unit.code2 ? ` / ${unit.code2}` : ""}
@ -171,7 +174,7 @@
title={note.title}
content={note.content}
unitCode={unit.code + (unit.code2 ? ` / ${unit.code2}` : "")}
meta="{note.authorName} · {timeAgo(
meta="{timeAgo(
note.createdAt,
)} · {note.commentCount} comment{note.commentCount === 1 ? '' : 's'}"
voteCount={note.voteCount}