diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..2c74cb3 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,36 @@ +name: CI + +on: + pull_request: + push: + branches: + - master + +jobs: + check: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Bun + uses: oven-sh/setup-bun@v2 + with: + bun-version: latest + + - name: Install dependencies + run: bun install --frozen-lockfile + + - name: Type check + run: bun run check + + - name: Unit tests + # Full `bun test` fails on a pre-existing svelte-sonner -> runed + # resolution error; run the green files until that is fixed. + run: bun test src/lib/content/seo-snapshot.test.ts src/lib/seo.test.ts + + - name: Build + run: bun run build + + - name: Check SEO build output + run: bun run check:seo diff --git a/.github/workflows/deploy-github-pages.yml b/.github/workflows/deploy-github-pages.yml index 845ae05..d18efe2 100644 --- a/.github/workflows/deploy-github-pages.yml +++ b/.github/workflows/deploy-github-pages.yml @@ -38,8 +38,8 @@ jobs: GITHUB_PAGES: 'true' run: bun run build - - name: Add SPA fallback for GitHub Pages - run: cp build/index.html build/404.html + - name: Check SEO build output + run: bun run check:seo - name: Upload Pages artifact uses: actions/upload-pages-artifact@v3 diff --git a/package.json b/package.json index 07380f8..a2897f5 100644 --- a/package.json +++ b/package.json @@ -6,6 +6,7 @@ "scripts": { "dev": "vite dev", "build": "vite build", + "check:seo": "bun scripts/check-seo-build.ts", "preview": "vite preview", "prepare": "svelte-kit sync || echo ''", "check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json", @@ -13,7 +14,8 @@ "format": "prettier --write .", "lint": "prettier --check . && eslint .", "test": "bun test", - "test:watch": "bun test --watch" + "test:watch": "bun test --watch", + "content:refresh": "bun scripts/refresh-seo-snapshot.ts" }, "devDependencies": { "@changesets/cli": "^2.31.1", diff --git a/scripts/check-seo-build.ts b/scripts/check-seo-build.ts new file mode 100644 index 0000000..a33195e --- /dev/null +++ b/scripts/check-seo-build.ts @@ -0,0 +1,222 @@ +import { readFileSync, readdirSync } from 'node:fs'; +import { getArticleIdentifier, seoSnapshot } from '../src/lib/content/seo-snapshot'; +import { nprofileEncode, npubEncode } from 'nostr-tools/nip19'; + +const siteOrigin = 'https://contextvm.org'; +const staticPages = new Map([ + ['index.html', '/'], + ['about.html', '/about'], + ['blog.html', '/blog'], + ['faqs.html', '/faqs'], + ['servers.html', '/servers'], + ['slides.html', '/slides'] +]); +const articlePages = new Map( + seoSnapshot.articles.flatMap((article) => { + const identifier = getArticleIdentifier(article); + return identifier + ? [[`blog/${identifier}.html`, `/blog/${encodeURIComponent(identifier)}`] as const] + : []; + }) +); +const canonicalServerPages = new Map( + seoSnapshot.servers.map( + (server) => [`s/${server.event.pubkey}.html`, `/s/${server.event.pubkey}`] as const + ) +); +const serverAliasPages = new Map( + seoSnapshot.servers.flatMap((server) => { + const canonicalPath = `/s/${server.event.pubkey}`; + const nprofile = nprofileEncode({ + pubkey: server.event.pubkey, + relays: server.relays + }); + return [ + [`s/${npubEncode(server.event.pubkey)}.html`, canonicalPath] as const, + [`s/${nprofile}.html`, canonicalPath] as const + ]; + }) +); +const serverPages = new Map([...canonicalServerPages, ...serverAliasPages]); +const pages = new Map([...staticPages, ...articlePages, ...serverPages]); + +function readBuildFile(path: string): string { + return readFileSync(new URL(`../build/${path}`, import.meta.url), 'utf8'); +} + +function schemaTypes(html: string, page: string): string[] { + const matches = [...html.matchAll(/ {fullTitle} - - - + - - - - - - + + + + + - - + - + + {@html ` @@ -57,7 +58,7 @@
@@ -172,7 +172,7 @@
- +
diff --git a/src/routes/about/+page.svelte b/src/routes/about/+page.svelte index d0c0090..1877d2d 100644 --- a/src/routes/about/+page.svelte +++ b/src/routes/about/+page.svelte @@ -1,7 +1,5 @@ - {#await marked.parse(aboutContent) then html} - {#if browser} - - {@html DOMPurify.sanitize(html)} - {/if} - {/await} + + + {@html aboutHtml} diff --git a/src/routes/blog/+page.svelte b/src/routes/blog/+page.svelte index 5b82710..29ce0ba 100644 --- a/src/routes/blog/+page.svelte +++ b/src/routes/blog/+page.svelte @@ -1,5 +1,6 @@ -{#if $storedArticle} - - {@const image = getArticleImage($storedArticle)} - {@const title = getArticleTitle($storedArticle)} - {@const publishedAt = formatUnixTimestamp($storedArticle.created_at, true)} +{#if articleEvent} + + {@const image = getArticleImage(articleEvent)} + {@const title = getArticleTitle(articleEvent)} + {@const publishedAt = formatUnixTimestamp(getArticlePublished(articleEvent), true)}
- - +
@@ -262,7 +323,7 @@
-
@@ -298,7 +359,7 @@ {/each}
-{:else if loading} +{:else if relayLoading}
@@ -327,11 +388,11 @@

The article you're looking for doesn't exist or couldn't be loaded.

- +
{/if} diff --git a/src/routes/blog/[id]/+page.ts b/src/routes/blog/[id]/+page.ts index d43d0cd..3b07a67 100644 --- a/src/routes/blog/[id]/+page.ts +++ b/src/routes/blog/[id]/+page.ts @@ -1 +1,15 @@ -export const prerender = false; +import { getArticleIdentifier, getSnapshotArticle, seoSnapshot } from '$lib/content/seo-snapshot'; +import type { EntryGenerator, PageLoad } from './$types'; + +export const prerender = true; + +export const entries: EntryGenerator = () => + seoSnapshot.articles.flatMap((article) => { + const id = getArticleIdentifier(article); + return id ? [{ id }] : []; + }); + +export const load = (({ params }) => ({ + article: getSnapshotArticle(params.id), + generatedAt: seoSnapshot.generatedAt +})) satisfies PageLoad; diff --git a/src/routes/chat/+page.svelte b/src/routes/chat/+page.svelte index a5f7677..382bd95 100644 --- a/src/routes/chat/+page.svelte +++ b/src/routes/chat/+page.svelte @@ -56,7 +56,7 @@ diff --git a/src/routes/faqs/+page.svelte b/src/routes/faqs/+page.svelte index 3f4d57d..80bfee9 100644 --- a/src/routes/faqs/+page.svelte +++ b/src/routes/faqs/+page.svelte @@ -1,16 +1,7 @@
-

-
- {#each faqCategories as category, categoryIndex (category.title)} - -
- {#if openCategoryIndex === categoryIndex} - -
- - + {#each faqCategories as category (category.title)} +
+ +

{category.title}

+
- -
- {#each category.faqs as faq (faq.question)} - - -

- {faq.question} -

- -
- -
- {#if browser} - - {@html DOMPurify.sanitize(faq.answer)} - {/if} -
-
-
- {/each} -
-
- {:else} - - - {/if} -
+
+ {#each category.faqs as faq (faq.question)} +
+ +

+ {faq.question} +

+
+
+ + + {@html faq.answer} +
+
+ {/each} +
+ {/each}
diff --git a/src/routes/robots.txt/+server.ts b/src/routes/robots.txt/+server.ts new file mode 100644 index 0000000..9fade73 --- /dev/null +++ b/src/routes/robots.txt/+server.ts @@ -0,0 +1,9 @@ +import { SITE_ORIGIN } from '$lib/seo'; + +export const prerender = true; + +export function GET() { + return new Response(`User-agent: *\nAllow: /\n\nSitemap: ${SITE_ORIGIN}/sitemap.xml\n`, { + headers: { 'Content-Type': 'text/plain; charset=utf-8' } + }); +} diff --git a/src/routes/s/[pubkey]/+page.svelte b/src/routes/s/[pubkey]/+page.svelte index e2d2bcc..7a9f77a 100644 --- a/src/routes/s/[pubkey]/+page.svelte +++ b/src/routes/s/[pubkey]/+page.svelte @@ -7,6 +7,8 @@ getAvailableCapabilities, pubkeyToHexColor, copyToClipboard, + decodeServerIdentifier, + encodeServerIdentity, resolveServerIdentifier } from '$lib/utils'; import { goto } from '$app/navigation'; @@ -54,11 +56,48 @@ import { createServerNotesFilter } from '$lib/constants'; import { npubEncode } from 'nostr-tools/nip19'; import { parseCapTagsFromEvent, parseCapTagsFromTags } from '$lib/services/payments/cep8-tags'; + import type { PageData } from './$types'; + + let { data }: { data: PageData } = $props(); const requestedIdentifier = page.params.pubkey ?? ''; + const snapshotAnnouncement = $derived( + data.server ? parseServerInitializeMsg(data.server.event) : null + ); + const snapshotIdentifier = $derived.by(() => { + if (!data.server) return undefined; + + const decoded = decodeServerIdentifier(requestedIdentifier); + if (decoded?.pubkey === data.server.event.pubkey) { + return { + ...decoded, + relayHints: decoded.relayHints.length ? decoded.relayHints : data.server.relays + }; + } + + return { + original: requestedIdentifier, + pubkey: data.server.event.pubkey, + relayHints: data.server.relays, + format: 'hex' as const + }; + }); + const snapshotIdentity = $derived.by(() => { + if (!snapshotAnnouncement || !data.server) return undefined; + + const relayHints = snapshotIdentifier?.relayHints ?? data.server.relays; + const relaySource: 'nprofile' | 'announcement' = + snapshotIdentifier?.format === 'nprofile' ? 'nprofile' : 'announcement'; + return { + ...encodeServerIdentity(snapshotAnnouncement.pubkey, relayHints), + relaySource + }; + }); + const resolvedIdentifierQuery = createQuery({ queryKey: ['server-identifier', requestedIdentifier, page.url.hostname], - queryFn: () => resolveServerIdentifier(requestedIdentifier, page.url.hostname) + queryFn: () => resolveServerIdentifier(requestedIdentifier, page.url.hostname), + initialData: () => snapshotIdentifier }); const resolvedIdentifier = $derived($resolvedIdentifierQuery.data ?? null); const pubkey = $derived(resolvedIdentifier?.pubkey ?? requestedIdentifier); @@ -73,18 +112,19 @@ const homeHref = $derived<`/`>('/'); const logoBlackSrc = asset('/logo-black.svg'); - // Dynamic SEO data for server pages - let seoTitle = $state('Loading server...'); - let seoDescription = $state('Loading server information...'); - let seoImage = $state(logoBlackSrc); - let seoUrl = $state(`https://contextvm.com/s/${requestedIdentifier}`); - let seoType = $state('website' as 'website' | 'article'); - const serverQuery = $derived( - resolvedIdentifier ? useServerAnnouncement(pubkey, bootstrapRelayHints) : undefined + resolvedIdentifier + ? useServerAnnouncement( + pubkey, + bootstrapRelayHints, + snapshotAnnouncement ? { server: snapshotAnnouncement, isPublic: true } : undefined + ) + : undefined ); const serverIdentityQuery = $derived( - resolvedIdentifier ? useServerIdentity(pubkey, bootstrapRelayHints) : undefined + resolvedIdentifier + ? useServerIdentity(pubkey, bootstrapRelayHints, snapshotIdentity) + : undefined ); const effectiveRelayHints = $derived( $serverIdentityQuery?.data?.relayHints?.length @@ -111,22 +151,23 @@ const hasNotes = $derived($notes.length > 0); - // Update SEO data when server loads - $effect(() => { - if ($serverQuery?.data?.server) { - const server = $serverQuery.data.server; - const serverName = server.name || 'Unnamed Server'; - const serverAbout = server.about || 'No description available for this server.'; - const serverPicture = server.picture || logoBlackSrc; - - seoTitle = serverName; - seoDescription = - serverAbout.length > 160 ? serverAbout.substring(0, 160) + '...' : serverAbout; - seoImage = serverPicture; - seoUrl = `https://contextvm.com/s/${requestedIdentifier}`; - seoType = 'website'; - } + const displayedServer = $derived($serverQuery?.data?.server ?? snapshotAnnouncement); + const seoTitle = $derived(displayedServer?.name || 'Server'); + const seoDescription = $derived.by(() => { + const about = displayedServer?.about || 'Public ContextVM server on Nostr.'; + return about.length > 160 ? about.substring(0, 157) + '...' : about; }); + const seoImage = $derived(displayedServer?.picture || logoBlackSrc); + const canonicalPath = $derived('/s/' + (displayedServer?.pubkey ?? pubkey)); + const liveStatus = $derived( + $serverQuery?.isError + ? 'unavailable' + : $serverQuery?.isFetching + ? 'connecting' + : ($serverQuery?.dataUpdatedAt ?? 0) > 0 + ? 'available' + : 'not checked' + ); // Only load queries for capabilities that are actually available let toolsQuery = $derived( @@ -275,7 +316,7 @@ } - + {#if $resolvedIdentifierQuery.isLoading}
@@ -296,13 +337,13 @@
{:else if $serverQuery?.data?.server}
- - + {#if $serverQuery.data.server.picture} @@ -327,9 +368,9 @@
-

+

{$serverQuery.data.server.name} -

+ {#if $serverQuery.data.server.website} @@ -342,6 +383,15 @@ {$serverQuery.data.server.website} {/if} +

+ Snapshot refreshed + . Live relay lookup is {liveStatus}. +

{#if hasPaidCapabilities} diff --git a/src/routes/s/[pubkey]/+page.ts b/src/routes/s/[pubkey]/+page.ts index d43d0cd..6761c59 100644 --- a/src/routes/s/[pubkey]/+page.ts +++ b/src/routes/s/[pubkey]/+page.ts @@ -1 +1,29 @@ -export const prerender = false; +import { building } from '$app/environment'; +import { error } from '@sveltejs/kit'; +import { getSnapshotServer, seoSnapshot } from '$lib/content/seo-snapshot'; +import { nprofileEncode, npubEncode } from 'nostr-tools/nip19'; +import type { EntryGenerator, PageLoad } from './$types'; + +export const prerender = true; + +export const entries: EntryGenerator = () => + seoSnapshot.servers.flatMap((server) => [ + { pubkey: server.event.pubkey }, + { pubkey: npubEncode(server.event.pubkey) }, + { + pubkey: nprofileEncode({ + pubkey: server.event.pubkey, + relays: server.relays + }) + } + ]); + +export const load = (({ params }) => { + const server = getSnapshotServer(params.pubkey); + if (building && !server) error(404, 'Server is not in the prerender snapshot'); + + return { + server, + generatedAt: seoSnapshot.generatedAt + }; +}) satisfies PageLoad; diff --git a/src/routes/servers/+page.svelte b/src/routes/servers/+page.svelte index 4374112..ff91819 100644 --- a/src/routes/servers/+page.svelte +++ b/src/routes/servers/+page.svelte @@ -1,4 +1,5 @@ @@ -91,6 +122,15 @@ Discover and connect with Model Context Protocol servers running on the Nostr network. No domains, no OAuth, no port forwarding—just cryptographic keys and relays.

+

+ Snapshot refreshed + . Live relay updates are {liveStatus}. +

@@ -175,7 +215,7 @@ {/each}
{/if} - {:else if !$serverAnnouncements?.length && !loading} + {:else if !allServerAnnouncements.length && !loading}

No MCP servers found. Check back later for server announcements.

diff --git a/src/routes/servers/+page.ts b/src/routes/servers/+page.ts new file mode 100644 index 0000000..9eaa40a --- /dev/null +++ b/src/routes/servers/+page.ts @@ -0,0 +1,7 @@ +import { seoSnapshot } from '$lib/content/seo-snapshot'; +import type { PageLoad } from './$types'; + +export const load = (() => ({ + servers: seoSnapshot.servers, + generatedAt: seoSnapshot.generatedAt +})) satisfies PageLoad; diff --git a/src/routes/servers/i/[hash]/+page.svelte b/src/routes/servers/i/[hash]/+page.svelte index c28ddb5..57d9cb6 100644 --- a/src/routes/servers/i/[hash]/+page.svelte +++ b/src/routes/servers/i/[hash]/+page.svelte @@ -69,8 +69,9 @@
diff --git a/src/routes/servers/t/[cat]/+page.svelte b/src/routes/servers/t/[cat]/+page.svelte index 58fa69c..1a075cd 100644 --- a/src/routes/servers/t/[cat]/+page.svelte +++ b/src/routes/servers/t/[cat]/+page.svelte @@ -69,8 +69,9 @@
diff --git a/src/routes/sitemap.xml/+server.ts b/src/routes/sitemap.xml/+server.ts new file mode 100644 index 0000000..ad62aac --- /dev/null +++ b/src/routes/sitemap.xml/+server.ts @@ -0,0 +1,26 @@ +import { canonicalUrl } from '$lib/seo'; +import { getArticleIdentifier, seoSnapshot } from '$lib/content/seo-snapshot'; + +export const prerender = true; + +const publicPages = ['/', '/about', '/blog', '/faqs', '/servers', '/slides']; + +export function GET() { + const articlePages = seoSnapshot.articles.flatMap((article) => { + const identifier = getArticleIdentifier(article); + return identifier ? [`/blog/${encodeURIComponent(identifier)}`] : []; + }); + const serverPages = seoSnapshot.servers.map((server) => `/s/${server.event.pubkey}`); + const urls = [...publicPages, ...articlePages, ...serverPages] + .map((path) => ` ${canonicalUrl(path)}`) + .join('\n'); + const sitemap = ` + +${urls} + +`; + + return new Response(sitemap, { + headers: { 'Content-Type': 'application/xml; charset=utf-8' } + }); +} diff --git a/src/routes/slides/+page.svelte b/src/routes/slides/+page.svelte index 9e9b44a..e40989e 100644 --- a/src/routes/slides/+page.svelte +++ b/src/routes/slides/+page.svelte @@ -1,6 +1,6 @@