From cc07c4166f9192511c05ba8fc0f29ec2ab480a86 Mon Sep 17 00:00:00 2001 From: manmohm <205255727+manmohm@users.noreply.github.com> Date: Mon, 24 Aug 2026 11:46:31 +0300 Subject: [PATCH] fix: add SEO metadata and generated sitemap --- README.md | 9 ++++++++ package.json | 4 +++- src/lib/site.ts | 15 +++++++++++++ src/routes/+page.svelte | 18 ++++++++++++++- src/routes/robots.txt/+server.ts | 16 +++++++++++++ src/routes/sitemap.xml/+server.ts | 37 +++++++++++++++++++++++++++++++ tests/seo.test.mjs | 37 +++++++++++++++++++++++++++++++ 7 files changed, 134 insertions(+), 2 deletions(-) create mode 100644 src/lib/site.ts create mode 100644 src/routes/robots.txt/+server.ts create mode 100644 src/routes/sitemap.xml/+server.ts create mode 100644 tests/seo.test.mjs diff --git a/README.md b/README.md index d4564d5..45e182c 100644 --- a/README.md +++ b/README.md @@ -28,6 +28,15 @@ npm run build npm run preview ``` +## Verify SEO output + +```bash +npm test +``` + +The check builds the static site and verifies its page metadata, canonical URL, +generated `sitemap.xml`, and `robots.txt` sitemap declaration. + ## Deploy to Cloudflare Pages (accountmap.org) This project is set up for [Cloudflare Pages](https://developers.cloudflare.com/pages/) with the [GitHub integration](https://developers.cloudflare.com/pages/configuration/git-integration/github-integration/). diff --git a/package.json b/package.json index f65a7d3..51d0daf 100644 --- a/package.json +++ b/package.json @@ -5,7 +5,9 @@ "scripts": { "dev": "vite dev", "build": "vite build", - "preview": "vite preview" + "preview": "vite preview", + "check:seo": "npm run build && node --test tests/seo.test.mjs", + "test": "npm run check:seo" }, "devDependencies": { "@sveltejs/adapter-auto": "^3.0.0", diff --git a/src/lib/site.ts b/src/lib/site.ts new file mode 100644 index 0000000..e5da609 --- /dev/null +++ b/src/lib/site.ts @@ -0,0 +1,15 @@ +export const siteMetadata = { + origin: 'https://accountmap.org', + name: 'AccountMap', + title: 'AccountMap — Visualize and manage your online accounts', + description: + 'Open-source account management app for tracking login methods and viewing accounts in 3D, 2D, or list views.', + locale: 'en_US', + socialImagePath: '/screenshots/1.png' +} as const; + +export const publicRoutes = ['/'] as const; + +export function absoluteUrl(pathname: string): string { + return new URL(pathname, `${siteMetadata.origin}/`).toString(); +} diff --git a/src/routes/+page.svelte b/src/routes/+page.svelte index 79ab360..9c55051 100644 --- a/src/routes/+page.svelte +++ b/src/routes/+page.svelte @@ -1,5 +1,6 @@ - AccountMap + {siteMetadata.title} + + + + + + + + + + + + + + +
diff --git a/src/routes/robots.txt/+server.ts b/src/routes/robots.txt/+server.ts new file mode 100644 index 0000000..ce20848 --- /dev/null +++ b/src/routes/robots.txt/+server.ts @@ -0,0 +1,16 @@ +import type { RequestHandler } from './$types'; +import { absoluteUrl } from '$lib/site'; + +export const prerender = true; + +export const GET: RequestHandler = () => + new Response(`User-agent: * +Allow: / + +Sitemap: ${absoluteUrl('/sitemap.xml')} +`, { + headers: { + 'Content-Type': 'text/plain; charset=utf-8', + 'Cache-Control': 'public, max-age=3600' + } + }); diff --git a/src/routes/sitemap.xml/+server.ts b/src/routes/sitemap.xml/+server.ts new file mode 100644 index 0000000..0e982f3 --- /dev/null +++ b/src/routes/sitemap.xml/+server.ts @@ -0,0 +1,37 @@ +import type { RequestHandler } from './$types'; +import { absoluteUrl, publicRoutes } from '$lib/site'; + +export const prerender = true; + +function escapeXml(value: string): string { + return value + .replaceAll('&', '&') + .replaceAll('<', '<') + .replaceAll('>', '>') + .replaceAll('"', '"') + .replaceAll("'", '''); +} + +export const GET: RequestHandler = () => { + const urls = publicRoutes + .map( + (pathname) => ` + ${escapeXml(absoluteUrl(pathname))} + ` + ) + .join('\n'); + + return new Response( + ` + +${urls} + +`, + { + headers: { + 'Content-Type': 'application/xml; charset=utf-8', + 'Cache-Control': 'public, max-age=3600' + } + } + ); +}; diff --git a/tests/seo.test.mjs b/tests/seo.test.mjs new file mode 100644 index 0000000..af8efab --- /dev/null +++ b/tests/seo.test.mjs @@ -0,0 +1,37 @@ +import assert from 'node:assert/strict'; +import { readFile } from 'node:fs/promises'; +import test from 'node:test'; + +const readBuildFile = (pathname) => + readFile(new URL(`../build/${pathname}`, import.meta.url), 'utf8'); + +test('build emits complete metadata for the landing page', async () => { + const html = await readBuildFile('index.html'); + + assert.match(html, /AccountMap — Visualize and manage your online accounts<\/title>/); + assert.match(html, /<meta name="description" content="[^"]{50,160}"\s*\/?>/); + assert.match(html, /<link rel="canonical" href="https:\/\/accountmap\.org\/"\s*\/?>/); + assert.match(html, /<meta property="og:title" content="[^"]+"\s*\/?>/); + assert.match(html, /<meta property="og:description" content="[^"]+"\s*\/?>/); + assert.match(html, /<meta property="og:url" content="https:\/\/accountmap\.org\/"\s*\/?>/); + assert.match(html, /<meta name="twitter:card" content="summary_large_image"\s*\/?>/); +}); + +test('build emits one canonical URL in a valid sitemap document', async () => { + const sitemap = await readBuildFile('sitemap.xml'); + + assert.match(sitemap, /^<\?xml version="1\.0" encoding="UTF-8"\?>/); + assert.match(sitemap, /<urlset xmlns="http:\/\/www\.sitemaps\.org\/schemas\/sitemap\/0\.9">/); + assert.match(sitemap, /<loc>https:\/\/accountmap\.org\/<\/loc>/); + assert.doesNotMatch(sitemap, /<!doctype html>/i); + assert.equal((sitemap.match(/<loc>/g) ?? []).length, 1, 'sitemap must not contain duplicate URLs'); +}); + +test('build emits crawler rules that point to the sitemap', async () => { + const robots = await readBuildFile('robots.txt'); + + assert.match(robots, /^User-agent: \*$/m); + assert.match(robots, /^Allow: \/$/m); + assert.match(robots, /^Sitemap: https:\/\/accountmap\.org\/sitemap\.xml$/m); + assert.doesNotMatch(robots, /<!doctype html>/i); +});