From 91f2d345a5d24fe8281cab3e9ca6ba22ee2d1363 Mon Sep 17 00:00:00 2001 From: Khushvendra Date: Thu, 24 Sep 2026 12:55:05 +0530 Subject: [PATCH 1/4] fix(seo): make core pages crawlable --- .github/workflows/deploy-github-pages.yml | 4 +- package.json | 1 + scripts/check-seo-build.ts | 119 ++++++++++++++++++ src/lib/components/SEO.svelte | 61 ++++++--- .../components/landing/DualApiSection.svelte | 3 +- .../landing/ValuePropsSection.svelte | 3 +- src/lib/seo.test.ts | 29 +++++ src/lib/seo.ts | 20 +++ src/routes/+error.svelte | 7 ++ src/routes/+layout.svelte | 3 - src/routes/+page.svelte | 4 +- src/routes/about/+page.svelte | 13 +- src/routes/blog/[id]/+page.svelte | 4 +- src/routes/chat/+page.svelte | 2 +- src/routes/faqs/+page.svelte | 110 ++++++---------- src/routes/robots.txt/+server.ts | 9 ++ src/routes/s/[pubkey]/+page.svelte | 4 +- src/routes/servers/i/[hash]/+page.svelte | 3 +- src/routes/servers/t/[cat]/+page.svelte | 3 +- src/routes/sitemap.xml/+server.ts | 20 +++ svelte.config.js | 7 +- 21 files changed, 306 insertions(+), 123 deletions(-) create mode 100644 scripts/check-seo-build.ts create mode 100644 src/lib/seo.test.ts 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 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..d0677ce 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", diff --git a/scripts/check-seo-build.ts b/scripts/check-seo-build.ts new file mode 100644 index 0000000..d2a4750 --- /dev/null +++ b/scripts/check-seo-build.ts @@ -0,0 +1,119 @@ +import { readFileSync } from 'node:fs'; + +const siteOrigin = 'https://contextvm.org'; +const pages = new Map([ + ['index.html', '/'], + ['about.html', '/about'], + ['blog.html', '/blog'], + ['faqs.html', '/faqs'], + ['servers.html', '/servers'], + ['slides.html', '/slides'] +]); + +function readBuildFile(path: string): string { + return readFileSync(new URL(`../build/${path}`, import.meta.url), 'utf8'); +} + +for (const [page, path] of pages) { + const html = readBuildFile(page); + const canonicalUrls = [...html.matchAll(/ match[1] + ); + const expectedCanonical = `${siteOrigin}${path}`; + + const titles = [...html.matchAll(/([^<]+)<\/title>/g)].map((match) => match[1]); + if (titles.length !== 1 || !titles[0].trim()) { + throw new Error(`${page} contains ${titles.length} non-empty titles; expected 1`); + } + + const descriptions = [...html.matchAll(/<meta name="description" content="([^"]+)"/g)].map( + (match) => match[1] + ); + if (descriptions.length !== 1 || !descriptions[0].trim()) { + throw new Error(`${page} contains ${descriptions.length} non-empty descriptions; expected 1`); + } + + if (canonicalUrls.length !== 1 || canonicalUrls[0] !== expectedCanonical) { + throw new Error( + `${page} has canonical ${JSON.stringify(canonicalUrls)}; expected ${expectedCanonical}` + ); + } + + const robots = [...html.matchAll(/<meta name="robots" content="([^"]+)"/g)].map( + (match) => match[1] + ); + if (robots.length !== 1 || robots[0] !== 'index, follow') { + throw new Error( + `${page} has robots directives ${JSON.stringify(robots)}; expected index, follow` + ); + } + + const openGraphUrls = [...html.matchAll(/<meta property="og:url" content="([^"]+)"/g)].map( + (match) => match[1] + ); + if (openGraphUrls.length !== 1 || openGraphUrls[0] !== expectedCanonical) { + throw new Error( + `${page} has Open Graph URLs ${JSON.stringify(openGraphUrls)}; expected ${expectedCanonical}` + ); + } + + const structuredDataMatches = [ + ...html.matchAll(/<script type="application\/ld\+json">([^<]+)<\/script>/g) + ]; + if (structuredDataMatches.length !== 1) { + throw new Error(`${page} contains ${structuredDataMatches.length} JSON-LD blocks; expected 1`); + } + + const structuredData = JSON.parse(structuredDataMatches[0][1]) as { + '@graph'?: Array<{ '@type'?: string }>; + }; + const schemaTypes = structuredData['@graph']?.map((entry) => entry['@type']) ?? []; + if (!schemaTypes.includes('Organization') || !schemaTypes.includes('WebSite')) { + throw new Error(`${page} is missing the Organization or WebSite schema`); + } + + if (html.includes('contextvm.com') || html.includes('sveltekit-prerender')) { + throw new Error(`${page} contains an obsolete or placeholder SEO URL`); + } +} + +const homepage = readBuildFile('index.html'); +if (!homepage.includes('<h1') || !homepage.includes('ContextVM')) { + throw new Error('index.html does not contain the prerendered homepage'); +} + +const about = readBuildFile('about.html'); +if (!about.includes('Imagine a world where anyone, anywhere')) { + throw new Error('about.html does not contain the About page copy'); +} + +const faqs = readBuildFile('faqs.html'); +if ( + !faqs.includes('What is ContextVM?') || + !faqs.includes('Rather than relying on centralized infrastructure') +) { + throw new Error('faqs.html does not contain the FAQ questions and answers'); +} + +const expectedSitemapUrls = [...pages.values()].map((path) => `${siteOrigin}${path}`); +const sitemapUrls = [...readBuildFile('sitemap.xml').matchAll(/<loc>([^<]+)<\/loc>/g)].map( + (match) => match[1] +); +if (JSON.stringify(sitemapUrls) !== JSON.stringify(expectedSitemapUrls)) { + throw new Error( + `sitemap.xml has URLs ${JSON.stringify(sitemapUrls)}; expected ${JSON.stringify(expectedSitemapUrls)}` + ); +} + +const robots = readBuildFile('robots.txt').trim(); +const expectedRobots = `User-agent: *\nAllow: /\n\nSitemap: ${siteOrigin}/sitemap.xml`; +if (robots !== expectedRobots) { + throw new Error('robots.txt does not match the expected crawl policy and sitemap URL'); +} + +const fallback = readBuildFile('404.html'); +if (fallback === homepage) { + throw new Error('404.html duplicates the prerendered homepage'); +} + +console.log('SEO build output is valid'); diff --git a/src/lib/components/SEO.svelte b/src/lib/components/SEO.svelte index 0eb80c1..a966ccd 100644 --- a/src/lib/components/SEO.svelte +++ b/src/lib/components/SEO.svelte @@ -1,9 +1,8 @@ <script lang="ts"> - import { asset } from '$app/paths'; - + import { asset, base } from '$app/paths'; import { page } from '$app/stores'; + import { absoluteSiteUrl, canonicalUrl, SITE_ORIGIN } from '$lib/seo'; - const defaultOgImageSrc = asset('/apple-touch-icon-180x180.png'); const favicon32Src = asset('/favicon-32x32.png'); const favicon16Src = asset('/favicon-16x16.png'); const appleTouchIconSrc = asset('/apple-touch-icon-180x180.png'); @@ -11,47 +10,69 @@ let { title = 'ContextVM', description = 'Discover and connect with Model Context Protocol (MCP) servers on Nostr.', - url = $page.url.href, - image = defaultOgImageSrc, + image = '/apple-touch-icon-180x180.png', type = 'website' as 'website' | 'article', siteName = 'ContextVM', - locale = 'en_US' + locale = 'en_US', + robots = 'index, follow' } = $props(); - // Generate the full title by combining site name and page title const fullTitle = $derived( - `${title || 'Untitled'} ${title === 'ContextVM' ? '' : `| ${siteName}`}`.trim() + `${title || 'Untitled'} ${title === siteName ? '' : `| ${siteName}`}`.trim() + ); + const url = $derived(canonicalUrl($page.url.pathname, base)); + const imageUrl = $derived(absoluteSiteUrl(image, base)); + const structuredData = $derived.by(() => + JSON.stringify({ + '@context': 'https://schema.org', + '@graph': [ + { + '@type': 'Organization', + '@id': `${SITE_ORIGIN}/#organization`, + name: siteName, + url: `${SITE_ORIGIN}/`, + logo: { + '@type': 'ImageObject', + url: `${SITE_ORIGIN}/apple-touch-icon-180x180.png` + } + }, + { + '@type': 'WebSite', + '@id': `${SITE_ORIGIN}/#website`, + name: siteName, + url: `${SITE_ORIGIN}/`, + publisher: { '@id': `${SITE_ORIGIN}/#organization` } + } + ] + }) ); </script> <svelte:head> <title>{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/[id]/+page.svelte b/src/routes/blog/[id]/+page.svelte index 885e351..a51e218 100644 --- a/src/routes/blog/[id]/+page.svelte +++ b/src/routes/blog/[id]/+page.svelte @@ -77,7 +77,6 @@ let seoTitle = $state('Loading...'); let seoDescription = $state('Loading article...'); let seoImage = $state(logoBlackSrc); - let seoUrl = $state(`https://contextvm.com/blog/${page.params.id}`); let seoType = $state('article' as 'website' | 'article'); // Update SEO data when article loads @@ -90,7 +89,6 @@ seoTitle = articleTitle; seoDescription = contentPreview; seoImage = articleImage || logoBlackSrc; - seoUrl = `https://contextvm.com/blog/${page.params.id}`; seoType = 'article'; } }); @@ -233,7 +231,7 @@ {#if $storedArticle} - + {@const image = getArticleImage($storedArticle)} {@const title = getArticleTitle($storedArticle)} {@const publishedAt = formatUnixTimestamp($storedArticle.created_at, true)} 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..52aaeb0 100644 --- a/src/routes/s/[pubkey]/+page.svelte +++ b/src/routes/s/[pubkey]/+page.svelte @@ -77,7 +77,6 @@ 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( @@ -123,7 +122,6 @@ seoDescription = serverAbout.length > 160 ? serverAbout.substring(0, 160) + '...' : serverAbout; seoImage = serverPicture; - seoUrl = `https://contextvm.com/s/${requestedIdentifier}`; seoType = 'website'; } }); @@ -275,7 +273,7 @@ } - + {#if $resolvedIdentifierQuery.isLoading}
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..564ddf5 --- /dev/null +++ b/src/routes/sitemap.xml/+server.ts @@ -0,0 +1,20 @@ +import { canonicalUrl } from '$lib/seo'; + +export const prerender = true; + +const publicPages = ['/', '/about', '/blog', '/faqs', '/servers', '/slides']; + +export function GET() { + const urls = publicPages + .map((path) => ` ${canonicalUrl(path)}`) + .join('\n'); + const sitemap = ` + +${urls} + +`; + + return new Response(sitemap, { + headers: { 'Content-Type': 'application/xml; charset=utf-8' } + }); +} diff --git a/svelte.config.js b/svelte.config.js index afd35b6..0c028d2 100644 --- a/svelte.config.js +++ b/svelte.config.js @@ -12,17 +12,18 @@ const config = { kit: { adapter: adapter({ - fallback: 'index.html' + fallback: '404.html' }), paths: { - base: isGitHubPages ? githubPagesBasePath : '' + base: isGitHubPages ? githubPagesBasePath : '', + relative: false }, alias: { '@/*': './path/to/lib/*' }, prerender: { crawl: true, - handleHttpError: 'ignore' + origin: isGitHubPages ? 'https://contextvm.github.io' : 'https://contextvm.org' } } }; From 62e71fb79eba9ed04520b14ec9a1fa32a7841277 Mon Sep 17 00:00:00 2001 From: Khushvendra Date: Thu, 24 Sep 2026 13:25:24 +0530 Subject: [PATCH 2/4] fix(seo): preserve deployment route semantics --- scripts/check-seo-build.ts | 29 +++++++++++++++++++++++++++++ src/routes/servers/+page.svelte | 3 ++- src/routes/slides/+page.svelte | 4 ++-- vercel.json | 2 +- 4 files changed, 34 insertions(+), 4 deletions(-) diff --git a/scripts/check-seo-build.ts b/scripts/check-seo-build.ts index d2a4750..03589ff 100644 --- a/scripts/check-seo-build.ts +++ b/scripts/check-seo-build.ts @@ -82,6 +82,25 @@ if (!homepage.includes(']*href="([^"]+)"/g)] + .map((match) => match[1]) + .filter((href) => href.startsWith('/') && !href.startsWith('//')); + const linksOutsideBase = internalLinks.filter( + (href) => href !== githubPagesBase && !href.startsWith(`${githubPagesBase}/`) + ); + + if (linksOutsideBase.length > 0) { + throw new Error( + `${page} has internal links outside ${githubPagesBase}: ${JSON.stringify(linksOutsideBase)}` + ); + } + } +} + const about = readBuildFile('about.html'); if (!about.includes('Imagine a world where anyone, anywhere')) { throw new Error('about.html does not contain the About page copy'); @@ -116,4 +135,14 @@ if (fallback === homepage) { throw new Error('404.html duplicates the prerendered homepage'); } +const vercelConfig = JSON.parse( + readFileSync(new URL('../vercel.json', import.meta.url), 'utf8') +) as { cleanUrls?: boolean; rewrites?: Array<{ source?: string; destination?: string }> }; +const hasIndexFallback = vercelConfig.rewrites?.some( + (rewrite) => rewrite.source === '/(.*)' && rewrite.destination === '/index.html' +); +if (vercelConfig.cleanUrls !== true || hasIndexFallback) { + throw new Error('vercel.json must use clean URLs without an index.html catch-all rewrite'); +} + console.log('SEO build output is valid'); diff --git a/src/routes/servers/+page.svelte b/src/routes/servers/+page.svelte index 4374112..fd23480 100644 --- a/src/routes/servers/+page.svelte +++ b/src/routes/servers/+page.svelte @@ -1,4 +1,5 @@ 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 @@ @@ -74,5 +91,5 @@ - {@html ` -{#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)}
- - +
@@ -260,7 +323,7 @@
-
@@ -296,7 +359,7 @@ {/each}
-{:else if loading} +{:else if relayLoading}
@@ -325,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/s/[pubkey]/+page.svelte b/src/routes/s/[pubkey]/+page.svelte index 52aaeb0..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,17 +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 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 @@ -110,21 +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; - 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( @@ -273,7 +316,7 @@ } - + {#if $resolvedIdentifierQuery.isLoading}
@@ -294,13 +337,13 @@
{:else if $serverQuery?.data?.server}
- - + {#if $serverQuery.data.server.picture} @@ -325,9 +368,9 @@
-

+

{$serverQuery.data.server.name} -

+ {#if $serverQuery.data.server.website} @@ -340,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 fd23480..ff91819 100644 --- a/src/routes/servers/+page.svelte +++ b/src/routes/servers/+page.svelte @@ -11,7 +11,11 @@ } from '$lib/queries/serverQueries'; import { serverKeys } from '$lib/queries/serverQueryKeys'; import { eventStore } from '$lib/services/eventStore'; - import { ServerAnnouncementsModel } from '$lib/models/serverAnnouncements'; + import { + parseServerInitializeMsg, + ServerAnnouncementsModel, + type ServerAnnouncement + } from '$lib/models/serverAnnouncements'; import { CatalogSchemasModel } from '$lib/models/catalogSchemas'; import { createCommonSchemaAnnouncementsLoader } from '$lib/services/loaders.svelte'; import Seo from '$lib/components/SEO.svelte'; @@ -19,7 +23,16 @@ import { Button } from '$lib/components/ui/button'; import CatalogBrowseSection from '$lib/components/CatalogBrowseSection.svelte'; import { decodeServerIdentifier, resolveServerIdentifier } from '$lib/utils'; + import type { PageData } from './$types'; + + let { data }: { data: PageData } = $props(); + const snapshotAnnouncements = $derived( + data.servers.flatMap((snapshot) => { + const server = parseServerInitializeMsg(snapshot.event); + return server ? [server] : []; + }) + ); const serverAnnouncements = eventStore.model(ServerAnnouncementsModel); const allSchemas = eventStore.model(CatalogSchemasModel); @@ -44,7 +57,24 @@ return () => sub.unsubscribe(); }); - let loading = $state($serverAnnouncementsQuery.isFetching); + const loading = $derived($serverAnnouncementsQuery.isFetching); + const liveStatus = $derived( + $serverAnnouncementsQuery.isError + ? 'unavailable' + : $serverAnnouncementsQuery.data + ? 'available' + : 'connecting' + ); + const allServerAnnouncements = $derived.by(() => { + const byPubkey = new Map(); + + for (const server of [...snapshotAnnouncements, ...$serverAnnouncements]) { + const current = byPubkey.get(server.pubkey); + if (!current || server.created_at > current.created_at) byPubkey.set(server.pubkey, server); + } + + return [...byPubkey.values()].sort((a, b) => b.created_at - a.created_at); + }); let searchTerm = $state(''); const resolvedSearchIdentifierQuery = $derived.by(() => { const trimmedSearchTerm = searchTerm.trim(); @@ -58,11 +88,11 @@ const filteredServerAnnouncements = $derived.by(() => { if (!searchTerm.trim()) { - return $serverAnnouncements; + return allServerAnnouncements; } const term = searchTerm.trim(); - return $serverAnnouncements?.filter((s) => matchesServerSearch(s, term)) ?? []; + return allServerAnnouncements.filter((server) => matchesServerSearch(server, term)); }); const decodedSearchIdentifier = $derived( @@ -92,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}. +

@@ -176,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/sitemap.xml/+server.ts b/src/routes/sitemap.xml/+server.ts index 564ddf5..ad62aac 100644 --- a/src/routes/sitemap.xml/+server.ts +++ b/src/routes/sitemap.xml/+server.ts @@ -1,11 +1,17 @@ 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 urls = publicPages + 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 = ` diff --git a/svelte.config.js b/svelte.config.js index 0c028d2..2528222 100644 --- a/svelte.config.js +++ b/svelte.config.js @@ -23,7 +23,17 @@ const config = { }, prerender: { crawl: true, - origin: isGitHubPages ? 'https://contextvm.github.io' : 'https://contextvm.org' + origin: isGitHubPages ? 'https://contextvm.github.io' : 'https://contextvm.org', + handleHttpError: ({ status, path, referrer, message }) => { + const isLiveServerLink = + status === 404 && path.startsWith('/s/') && referrer?.startsWith('/blog/'); + const isDocsMirrorLink = + isGitHubPages && + status === 404 && + path.startsWith('/contextvm-docs/') && + referrer?.startsWith(`${githubPagesBasePath}/blog/`); + if (!isLiveServerLink && !isDocsMirrorLink) throw new Error(message); + } } } }; From ddccd7c60ea312e7b85d5ffa247bed462d0d416e Mon Sep 17 00:00:00 2001 From: ContextVM Date: Tue, 6 Oct 2026 17:12:45 +0100 Subject: [PATCH 4/4] fix(seo): validate curated server tag URLs and add PR CI - Reject server announcements whose website/picture tags are not plain http(s) URLs before they enter the prerendered snapshot (tags bypass InitializeResultSchema content validation and are rendered into href/src attributes on prerendered pages). - Add a PR CI workflow running svelte-check, targeted unit tests, build, and check:seo so output regressions gate PRs instead of only the GitHub Pages deploy. - Clarify the missing-snapshot error in content:refresh. --- .github/workflows/ci.yml | 36 +++++++++++++++++++++++++++++++++ scripts/refresh-seo-snapshot.ts | 24 ++++++++++++++++++++-- 2 files changed, 58 insertions(+), 2 deletions(-) create mode 100644 .github/workflows/ci.yml 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/scripts/refresh-seo-snapshot.ts b/scripts/refresh-seo-snapshot.ts index d4f8f8c..7efe23d 100644 --- a/scripts/refresh-seo-snapshot.ts +++ b/scripts/refresh-seo-snapshot.ts @@ -31,6 +31,18 @@ function tagValue(event: Event, name: string): string | null { return event.tags.find((tag) => tag[0] === name)?.[1] || null; } +// Website/picture tags are rendered into prerendered href/src attributes, so +// anything that is not a plain http(s) URL is rejected at this trust boundary. +function safeHttpUrl(value: string | null): boolean { + if (value === null) return true; + try { + const protocol = new URL(value).protocol; + return protocol === 'http:' || protocol === 'https:'; + } catch { + return false; + } +} + function validSignedEvent(event: Event): boolean { return validateEvent(event) && verifyEvent(event); } @@ -59,10 +71,12 @@ function validServer(event: Event, pubkey: string): boolean { try { const content = JSON.parse(event.content); const initializeResult = 'result' in content ? content.result : content; - return InitializeResultSchema.safeParse(initializeResult).success; + if (!InitializeResultSchema.safeParse(initializeResult).success) return false; } catch { return false; } + + return safeHttpUrl(tagValue(event, 'website')) && safeHttpUrl(tagValue(event, 'picture')); } function latestByIdentifier(events: Event[]): Event[] { @@ -80,7 +94,13 @@ function latestByIdentifier(events: Event[]): Event[] { } async function readPreviousSnapshot(): Promise { - return JSON.parse(await readFile(SNAPSHOT_PATH, 'utf8')) as SeoSnapshot; + try { + return JSON.parse(await readFile(SNAPSHOT_PATH, 'utf8')) as SeoSnapshot; + } catch (error) { + throw new Error( + `Could not read the previous snapshot at ${SNAPSHOT_PATH.pathname} (restore it from git history if it was deleted): ${error}` + ); + } } async function refresh() {