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: