Add cache headers, real 404s, social meta, JSON-LD, robots and sitemap

This commit is contained in:
dtonon 2026-08-18 17:49:01 +01:00
parent 2041f6b899
commit fc7c749856
19 changed files with 258 additions and 47 deletions

View file

@ -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<ForumShell>(fetch, "/api/shell") };
}

View file

@ -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 @@
}
</script>
<svelte:head>
<title>{MODE === "full" ? "Rooms" : "Discussions"}</title>
</svelte:head>
<Meta title={MODE === "full" ? "Rooms" : "Discussions"} {description} {image} />
{#snippet pic(a: { name: string; picture?: string })}
{#if a.picture}

View file

@ -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) });
}

View file

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

View file

@ -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());
}

View file

@ -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");

View file

@ -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");

View file

@ -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 @@
}
</script>
<svelte:head>
<title>Contacts</title>
<meta name="description" content="Admins and contacts for this community." />
</svelte:head>
<Meta title="Contacts" description="Admins and contacts for this community." />
<div class="mx-auto max-w-6xl">
<h1 class="text-accent py-2 text-[1.65rem]">Contacts</h1>

View file

@ -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 ?? ""));
</script>
<svelte:head>
<title>{resource?.title ?? "Resource"}</title>
{#if description}
<meta name="description" content={description} />
{/if}
</svelte:head>
<Meta title={resource?.title ?? "Resource"} {description} type="article" />
<div class="mx-auto max-w-6xl">
<a
@ -56,8 +45,12 @@
<PostContent content={resource.content} headingOffset={0} />
</div>
{:else if notFound}
<p class="py-12 text-center text-neutral-400 dark:text-neutral-500">Resource not found.</p>
<p class="py-12 text-center text-neutral-400 dark:text-neutral-500">
Resource not found.
</p>
{:else}
<p class="py-12 text-center text-neutral-400 dark:text-neutral-500">Loading…</p>
<p class="py-12 text-center text-neutral-400 dark:text-neutral-500">
Loading…
</p>
{/if}
</div>

View file

@ -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<OverviewSnapshot>(fetch, "/api/overview");
return { overview, profiles: overview?.profiles ?? null };
}

View file

@ -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" },
});
}

View file

@ -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 = `<loc>${origin}${e.path}</loc>`;
const mod = e.lastmod
? `<lastmod>${new Date(e.lastmod * 1000).toISOString()}</lastmod>`
: "";
return `<url>${loc}${mod}</url>`;
});
return (
'<?xml version="1.0" encoding="UTF-8"?>' +
'<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">' +
items.join("") +
"</urlset>"
);
}
// 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" },
});
}

View file

@ -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 @@
});
</script>
<svelte:head>
<title>{detail?.title ?? "Thread"}</title>
</svelte:head>
<Meta
title={detail?.title ?? "Thread"}
description={detail ? excerpt(detail.op.content) : ""}
type="article"
{jsonLd}
/>
{#snippet avatar(author: Author)}
{#if author.picture}