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

@ -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 @@
);
</script>
<svelte:head>
<title>{title}</title>
</svelte:head>
<Meta {title} description={about} />
<div class="mx-auto max-w-6xl">
<div class="flex flex-wrap items-center justify-between gap-2 py-2">

View file

@ -0,0 +1,57 @@
<script lang="ts">
import { page } from "$app/state";
import { TITLE } from "$lib/config";
import { groupStore } from "$lib/group.svelte";
import { GROUP_ID, MODE } from "$lib/config";
type Props = {
title: string;
description?: string;
type?: "website" | "article";
image?: string;
jsonLd?: Record<string, unknown>;
};
let {
title,
description = "",
type = "website",
image,
jsonLd,
}: Props = $props();
// Escape "<" so the JSON can never close the script tag early.
const ld = $derived(
jsonLd ? JSON.stringify(jsonLd).replace(/</g, "\\u003c") : "",
);
const siteName = $derived(
TITLE || (MODE === "simple" ? (groupStore.data?.name ?? GROUP_ID) : ""),
);
const canonical = $derived(page.url.origin + page.url.pathname);
</script>
<svelte:head>
<title>{title}</title>
<link rel="canonical" href={canonical} />
{#if description}
<meta name="description" content={description} />
<meta property="og:description" content={description} />
{/if}
<meta property="og:title" content={title} />
<meta property="og:type" content={type} />
<meta property="og:url" content={canonical} />
{#if siteName}
<meta property="og:site_name" content={siteName} />
{/if}
{#if image}
<meta property="og:image" content={image} />
{/if}
<meta
name="twitter:card"
content={image ? "summary_large_image" : "summary"}
/>
{#if ld}
{@html `<script type="application/ld+json">${ld}</script>`}
{/if}
</svelte:head>

View file

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

View file

@ -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<T>(
fetch: Fetch,
url: string,
): Promise<T | null> {
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;

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

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