From fc7c7498563c4d8a73c8a312a0a3fc5040e2bae6 Mon Sep 17 00:00:00 2001 From: dtonon Date: Tue, 18 Aug 2026 17:49:01 +0100 Subject: [PATCH] Add cache headers, real 404s, social meta, JSON-LD, robots and sitemap --- src/lib/components/DiscussionsFeed.svelte | 14 ++++-- src/lib/components/Meta.svelte | 57 +++++++++++++++++++++++ src/lib/config.ts | 5 ++ src/lib/forum/snapshot.ts | 13 +++++- src/lib/seo.ts | 17 +++++++ src/routes/+layout.ts | 7 ++- src/routes/+page.svelte | 20 ++++++-- src/routes/api/contacts/+server.ts | 4 +- src/routes/api/overview/+server.ts | 5 +- src/routes/api/shell/+server.ts | 4 +- src/routes/api/thread/[id]/+server.ts | 5 +- src/routes/api/threads/[group]/+server.ts | 5 +- src/routes/contacts/+page.svelte | 6 +-- src/routes/resource/[slug]/+page.svelte | 27 ++++------- src/routes/resource/[slug]/+page.ts | 14 +++++- src/routes/robots.txt/+server.ts | 13 ++++++ src/routes/sitemap.xml/+server.ts | 56 ++++++++++++++++++++++ src/routes/thread/[id]/+page.svelte | 30 ++++++++++-- static/robots.txt | 3 -- 19 files changed, 258 insertions(+), 47 deletions(-) create mode 100644 src/lib/components/Meta.svelte create mode 100644 src/lib/seo.ts create mode 100644 src/routes/robots.txt/+server.ts create mode 100644 src/routes/sitemap.xml/+server.ts delete mode 100644 static/robots.txt diff --git a/src/lib/components/DiscussionsFeed.svelte b/src/lib/components/DiscussionsFeed.svelte index e914916..1999ca4 100644 --- a/src/lib/components/DiscussionsFeed.svelte +++ b/src/lib/components/DiscussionsFeed.svelte @@ -11,6 +11,10 @@ type Author, } from "$lib/components/ThreadItem.svelte"; import SortToggle from "$lib/components/SortToggle.svelte"; + import Meta from "$lib/components/Meta.svelte"; + import { groupStore } from "$lib/group.svelte"; + import { groupsStore } from "$lib/groups.svelte"; + import { MODE } from "$lib/config"; import type { NostrUser } from "@nostr/gadgets/metadata"; import { auth, openLogin } from "$lib/auth.svelte"; import { openDraft } from "$lib/draft.svelte"; @@ -45,6 +49,12 @@ if (urlSort) sortPref.value = urlSort; }); + const about = $derived( + MODE === "full" + ? (groupsStore.list.find((g) => g.id === groupId)?.about ?? "") + : (groupStore.data?.about ?? ""), + ); + const flags = $derived(getGroupFlags(groupId)); const member = $derived(membershipOf(groupId)); @@ -123,9 +133,7 @@ ); - - {title} - +
diff --git a/src/lib/components/Meta.svelte b/src/lib/components/Meta.svelte new file mode 100644 index 0000000..6bc45e8 --- /dev/null +++ b/src/lib/components/Meta.svelte @@ -0,0 +1,57 @@ + + + + {title} + + {#if description} + + + {/if} + + + + {#if siteName} + + {/if} + {#if image} + + {/if} + + {#if ld} + {@html ``} + {/if} + diff --git a/src/lib/config.ts b/src/lib/config.ts index 08a9820..95f70f6 100644 --- a/src/lib/config.ts +++ b/src/lib/config.ts @@ -21,6 +21,11 @@ if (MODE === "simple" && !GROUP_ID) { // Server-side rendering: opt-in, and only meaningful when the build targets the // Node adapter (svelte.config.js reads the same variable). export const SSR_ENABLED = env.PUBLIC_SSR === "yes"; +// Server-rendered pages and snapshots are anonymous, so a shared cache may +// hold them: fresh for 5 minutes, served stale for an hour while revalidating. +// Browsers always revalidate (max-age=0) so a login shows its content at once. +export const CACHE_CONTROL = + "public, max-age=0, s-maxage=300, stale-while-revalidate=3600"; // Requires a relay with NIP-50 support. export const SEARCH_ENABLED = env.PUBLIC_SEARCH === "yes"; export const LABELS = (env.PUBLIC_LABELS ?? "") diff --git a/src/lib/forum/snapshot.ts b/src/lib/forum/snapshot.ts index 5a667a4..439f058 100644 --- a/src/lib/forum/snapshot.ts +++ b/src/lib/forum/snapshot.ts @@ -1,4 +1,5 @@ import { browser } from "$app/environment"; +import { error } from "@sveltejs/kit"; import type { NostrUser } from "$lib/gadgets"; import type { GroupMetadata, GroupSummary } from "./groups"; import type { Resource } from "./resources"; @@ -37,14 +38,22 @@ type Fetch = typeof fetch; // HTML; in the browser it is consumed only while hydrating that HTML. Client // navigations get null, leaving the stores to fetch live (and authenticated) // data as they always did — the same path a client-only build takes. +// A 404 from the endpoint becomes a real 404 page on the server, so crawlers +// never index an empty shell; the client keeps its own not-found handling. export async function snapshot( fetch: Fetch, url: string, ): Promise { if (browser && !hasInlinedResponse(url)) return null; + let res: Response; + try { + res = await fetch(url); + } catch { + return null; + } + if (res.status === 404 && !browser) error(404, "Not found"); + if (!res.ok) return null; try { - const res = await fetch(url); - if (!res.ok) return null; return (await res.json()) as T; } catch { return null; diff --git a/src/lib/seo.ts b/src/lib/seo.ts new file mode 100644 index 0000000..961a4e1 --- /dev/null +++ b/src/lib/seo.ts @@ -0,0 +1,17 @@ +// Plain-text excerpt of a markdown body for description meta tags: the first +// paragraph that isn't a heading or a bare media URL, trimmed to `max` chars. +export function excerpt(markdown: string, max = 160): string { + const lines = markdown.split("\n").map((l) => l.trim()); + const line = lines.find( + (l) => l && !l.startsWith("#") && !/^https?:\/\/\S+$/.test(l), + ); + if (!line) return ""; + const text = line + .replace(/!\[[^\]]*\]\([^)]*\)/g, "") // images + .replace(/\[([^\]]*)\]\([^)]*\)/g, "$1") // links → label + .replace(/[*_`~]+/g, "") + .replace(/nostr:([a-z0-9]{12})[a-z0-9]*/g, "@$1…") // keep a short handle + .replace(/\s+/g, " ") + .trim(); + return text.length > max ? text.slice(0, max - 1).trimEnd() + "…" : text; +} diff --git a/src/routes/+layout.ts b/src/routes/+layout.ts index 7ecaeb3..2946adc 100644 --- a/src/routes/+layout.ts +++ b/src/routes/+layout.ts @@ -1,8 +1,11 @@ -import { SSR_ENABLED } from "$lib/config"; +import { browser } from "$app/environment"; +import { CACHE_CONTROL, SSR_ENABLED } from "$lib/config"; import { snapshot, type ForumShell } from "$lib/forum/snapshot"; export const ssr = SSR_ENABLED; -export async function load({ fetch }) { +// Runs for every page, so the cache policy for rendered HTML is set here. +export async function load({ fetch, setHeaders }) { + if (!browser) setHeaders({ "cache-control": CACHE_CONTROL }); return { shell: await snapshot(fetch, "/api/shell") }; } diff --git a/src/routes/+page.svelte b/src/routes/+page.svelte index 0a006cd..5613fde 100644 --- a/src/routes/+page.svelte +++ b/src/routes/+page.svelte @@ -7,10 +7,26 @@ import { partialsStore } from "$lib/partials.svelte"; import { MODE, GROUP_ID, SEARCH_ENABLED } from "$lib/config"; import SearchInline from "$lib/components/SearchInline.svelte"; + import Meta from "$lib/components/Meta.svelte"; + import { groupStore } from "$lib/group.svelte"; + import { excerpt } from "$lib/seo"; import type { NostrUser } from "@nostr/gadgets/metadata"; const partial = $derived(partialsStore.get("home")); + const description = $derived( + partial + ? excerpt(partial.content) + : MODE === "simple" + ? (groupStore.data?.about ?? "") + : "", + ); + // A partial's leading image doubles as the social preview. + const image = $derived( + partial?.content.match(/^https?:\/\/\S+\.(?:png|jpe?g|webp|gif)$/im)?.[0] ?? + groupStore.data?.picture, + ); + // Full mode: load per-room activity + recent threads once the rooms are known. $effect(() => { if (MODE !== "full") return; @@ -35,9 +51,7 @@ } - - {MODE === "full" ? "Rooms" : "Discussions"} - + {#snippet pic(a: { name: string; picture?: string })} {#if a.picture} diff --git a/src/routes/api/contacts/+server.ts b/src/routes/api/contacts/+server.ts index ab0cf62..5cdb082 100644 --- a/src/routes/api/contacts/+server.ts +++ b/src/routes/api/contacts/+server.ts @@ -1,9 +1,11 @@ import { json } from "@sveltejs/kit"; +import { CACHE_CONTROL } from "$lib/config"; import { fetchProfiles } from "$lib/forum/profiles"; import { loadShell } from "$lib/ssr/shell"; import { profileQuery } from "$lib/ssr/relay"; -export async function GET() { +export async function GET({ setHeaders }) { + setHeaders({ "cache-control": CACHE_CONTROL }); const shell = await loadShell(); return json({ profiles: await fetchProfiles(profileQuery, shell.admins) }); } diff --git a/src/routes/api/overview/+server.ts b/src/routes/api/overview/+server.ts index a6171d6..31dd1b0 100644 --- a/src/routes/api/overview/+server.ts +++ b/src/routes/api/overview/+server.ts @@ -1,5 +1,5 @@ import { json } from "@sveltejs/kit"; -import { MODE, GROUP_ID } from "$lib/config"; +import { MODE, GROUP_ID, CACHE_CONTROL } from "$lib/config"; import { fetchGroups } from "$lib/forum/groups"; import { fetchOverview, overviewPubkeys } from "$lib/forum/overview"; import { fetchProfiles } from "$lib/forum/profiles"; @@ -8,7 +8,8 @@ import { forumQuery, profileQuery } from "$lib/ssr/relay"; // Room activity and the latest discussions: every room in full mode, the // single group in simple mode (mirrors LatestDiscussions' own room set). -export async function GET() { +export async function GET({ setHeaders }) { + setHeaders({ "cache-control": CACHE_CONTROL }); const roomIds = MODE === "full" ? (await fetchGroups(forumQuery)).map((g) => g.id) diff --git a/src/routes/api/shell/+server.ts b/src/routes/api/shell/+server.ts index f2c0519..bb801dd 100644 --- a/src/routes/api/shell/+server.ts +++ b/src/routes/api/shell/+server.ts @@ -1,6 +1,8 @@ import { json } from "@sveltejs/kit"; +import { CACHE_CONTROL } from "$lib/config"; import { loadShell } from "$lib/ssr/shell"; -export async function GET() { +export async function GET({ setHeaders }) { + setHeaders({ "cache-control": CACHE_CONTROL }); return json(await loadShell()); } diff --git a/src/routes/api/thread/[id]/+server.ts b/src/routes/api/thread/[id]/+server.ts index 2cb4e71..28c4b0e 100644 --- a/src/routes/api/thread/[id]/+server.ts +++ b/src/routes/api/thread/[id]/+server.ts @@ -1,10 +1,11 @@ import { error, json } from "@sveltejs/kit"; -import { GROUP_ID } from "$lib/config"; +import { GROUP_ID, CACHE_CONTROL } from "$lib/config"; import { fetchThread, threadAuthors } from "$lib/forum/thread"; import { fetchProfiles } from "$lib/forum/profiles"; import { forumQuery, profileQuery } from "$lib/ssr/relay"; -export async function GET({ params }) { +export async function GET({ params, setHeaders }) { + setHeaders({ "cache-control": CACHE_CONTROL }); if (!/^[0-9a-f]{64}$/.test(params.id)) error(404, "Not found"); const thread = await fetchThread(forumQuery, params.id, GROUP_ID); if (!thread) error(404, "Not found"); diff --git a/src/routes/api/threads/[group]/+server.ts b/src/routes/api/threads/[group]/+server.ts index 4ed8fa8..20f30b8 100644 --- a/src/routes/api/threads/[group]/+server.ts +++ b/src/routes/api/threads/[group]/+server.ts @@ -1,5 +1,5 @@ import { error, json } from "@sveltejs/kit"; -import { MODE, GROUP_ID } from "$lib/config"; +import { MODE, GROUP_ID, CACHE_CONTROL } from "$lib/config"; import { fetchGroups } from "$lib/forum/groups"; import { fetchThreadPage, @@ -12,7 +12,8 @@ import { forumQuery, profileQuery } from "$lib/ssr/relay"; // First page of a group's listing. Unknown rooms are a 404 so crawlers don't // index empty shells; private rooms are simply empty to the anonymous server. -export async function GET({ params, url }) { +export async function GET({ params, url, setHeaders }) { + setHeaders({ "cache-control": CACHE_CONTROL }); const groupId = params.group; if (MODE === "simple") { if (groupId !== GROUP_ID) error(404, "Not found"); diff --git a/src/routes/contacts/+page.svelte b/src/routes/contacts/+page.svelte index bab7483..313431c 100644 --- a/src/routes/contacts/+page.svelte +++ b/src/routes/contacts/+page.svelte @@ -16,6 +16,7 @@ import { partialsStore } from "$lib/partials.svelte"; import PostContent from "$lib/components/PostContent.svelte"; import { page } from "$app/state"; + import Meta from "$lib/components/Meta.svelte"; const partial = $derived(partialsStore.get("contacts")); @@ -86,10 +87,7 @@ } - - Contacts - - +

Contacts

diff --git a/src/routes/resource/[slug]/+page.svelte b/src/routes/resource/[slug]/+page.svelte index 310d833..c7f647a 100644 --- a/src/routes/resource/[slug]/+page.svelte +++ b/src/routes/resource/[slug]/+page.svelte @@ -3,6 +3,8 @@ import { resourcesStore } from "$lib/resources.svelte"; import { adminPubkeys } from "$lib/admins.svelte"; import PostContent from "$lib/components/PostContent.svelte"; + import Meta from "$lib/components/Meta.svelte"; + import { excerpt } from "$lib/seo"; const slug = $derived(page.params.slug ?? ""); const resource = $derived(resourcesStore.list.find((r) => r.slug === slug)); @@ -10,23 +12,10 @@ const ready = $derived(resourcesStore.loaded && adminPubkeys.loaded); const notFound = $derived(ready && !resource); - // First non-empty line of the body, trimmed of markdown markers, for SEO. - const description = $derived( - (resource?.content ?? "") - .replace(/^#+\s*/gm, "") - .split("\n") - .map((l) => l.trim()) - .find(Boolean) - ?.slice(0, 160) ?? "", - ); + const description = $derived(excerpt(resource?.content ?? "")); - - {resource?.title ?? "Resource"} - {#if description} - - {/if} - + {:else if notFound} -

Resource not found.

+

+ Resource not found. +

{:else} -

Loading…

+

+ Loading… +

{/if}
diff --git a/src/routes/resource/[slug]/+page.ts b/src/routes/resource/[slug]/+page.ts index 7b54f01..e3032e6 100644 --- a/src/routes/resource/[slug]/+page.ts +++ b/src/routes/resource/[slug]/+page.ts @@ -1,7 +1,17 @@ +import { browser } from "$app/environment"; +import { error } from "@sveltejs/kit"; import { snapshot, type OverviewSnapshot } from "$lib/forum/snapshot"; -// Articles show the latest-discussions panel next to the text. -export async function load({ fetch }) { +// Articles show the latest-discussions panel next to the text. An unknown +// slug is a real 404 on the server; the client keeps its in-page message. +export async function load({ fetch, params, parent }) { + if (!browser) { + const { shell } = await parent(); + const known = shell?.resources.some( + (r) => r.slug === params.slug && shell.admins.includes(r.pubkey), + ); + if (shell && !known) error(404, "Not found"); + } const overview = await snapshot(fetch, "/api/overview"); return { overview, profiles: overview?.profiles ?? null }; } diff --git a/src/routes/robots.txt/+server.ts b/src/routes/robots.txt/+server.ts new file mode 100644 index 0000000..fb2f8fc --- /dev/null +++ b/src/routes/robots.txt/+server.ts @@ -0,0 +1,13 @@ +import { SSR_ENABLED } from "$lib/config"; + +// Static builds bake it in at build time (no sitemap to point at); the server +// build answers live so the sitemap URL carries the real origin. +export const prerender = !SSR_ENABLED; + +export function GET({ url }) { + const lines = ["User-agent: *", "Disallow:"]; + if (SSR_ENABLED) lines.push(`Sitemap: ${url.origin}/sitemap.xml`); + return new Response(lines.join("\n") + "\n", { + headers: { "content-type": "text/plain" }, + }); +} diff --git a/src/routes/sitemap.xml/+server.ts b/src/routes/sitemap.xml/+server.ts new file mode 100644 index 0000000..4ce5d18 --- /dev/null +++ b/src/routes/sitemap.xml/+server.ts @@ -0,0 +1,56 @@ +import { CACHE_CONTROL, MODE, GROUP_ID } from "$lib/config"; +import { fetchGroups } from "$lib/forum/groups"; +import { forumQuery } from "$lib/ssr/relay"; +import { loadShell } from "$lib/ssr/shell"; + +const THREADS_PER_ROOM = 1000; + +type Entry = { path: string; lastmod?: number }; + +function xml(origin: string, entries: Entry[]): string { + const items = entries.map((e) => { + const loc = `${origin}${e.path}`; + const mod = e.lastmod + ? `${new Date(e.lastmod * 1000).toISOString()}` + : ""; + return `${loc}${mod}`; + }); + return ( + '' + + '' + + items.join("") + + "" + ); +} + +// Public pages only: rooms the anonymous server can see, their threads, and +// the admin-authored resources. +export async function GET({ url, setHeaders }) { + const entries: Entry[] = [{ path: "/" }, { path: "/contacts" }]; + const shell = await loadShell(); + const admins = new Set(shell.admins); + for (const r of shell.resources) { + if (admins.has(r.pubkey)) + entries.push({ path: `/resource/${r.slug}`, lastmod: r.createdAt }); + } + const roomIds = + MODE === "full" + ? (await fetchGroups(forumQuery)) + .filter((g) => !g.flags.includes("private")) + .map((g) => g.id) + : [GROUP_ID]; + for (const id of roomIds) { + if (MODE === "full") entries.push({ path: `/room/${id}` }); + const ops = await forumQuery({ + kinds: [11], + "#h": [id], + limit: THREADS_PER_ROOM, + }); + for (const e of ops) + entries.push({ path: `/thread/${e.id}`, lastmod: e.created_at }); + } + setHeaders({ "cache-control": CACHE_CONTROL }); + return new Response(xml(url.origin, entries), { + headers: { "content-type": "application/xml" }, + }); +} diff --git a/src/routes/thread/[id]/+page.svelte b/src/routes/thread/[id]/+page.svelte index 20c673f..8e36491 100644 --- a/src/routes/thread/[id]/+page.svelte +++ b/src/routes/thread/[id]/+page.svelte @@ -30,6 +30,8 @@ import MessageEditor from "$lib/components/MessageEditor.svelte"; import PostContent from "$lib/components/PostContent.svelte"; import Tag from "$lib/components/Tag.svelte"; + import Meta from "$lib/components/Meta.svelte"; + import { excerpt } from "$lib/seo"; import type { NostrUser } from "$lib/gadgets"; type Author = { pubkey: string; name: string; picture?: string }; @@ -120,6 +122,25 @@ const detail = $derived(threadDetailStore.detail); const profiles = $derived(threadDetailStore.profiles); + // Structured data for crawlers: the OP as a forum posting. + const jsonLd = $derived( + detail + ? { + "@context": "https://schema.org", + "@type": "DiscussionForumPosting", + headline: detail.title, + text: detail.op.content, + url: page.url.origin + page.url.pathname, + datePublished: new Date(detail.op.createdAt * 1000).toISOString(), + author: { + "@type": "Person", + name: resolveAuthor(detail.op.pubkey, profiles).name, + }, + commentCount: detail.replies.length, + } + : undefined, + ); + // Replying needs membership regardless of flags. Only gate a confirmed guest; // while membership resolves the editor stays and submit awaits the check, so a // member never flashes "Join to reply". @@ -322,9 +343,12 @@ }); - - {detail?.title ?? "Thread"} - + {#snippet avatar(author: Author)} {#if author.picture} diff --git a/static/robots.txt b/static/robots.txt deleted file mode 100644 index b6dd667..0000000 --- a/static/robots.txt +++ /dev/null @@ -1,3 +0,0 @@ -# allow crawling everything by default -User-agent: * -Disallow: