Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/).
Expand Down
4 changes: 3 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
15 changes: 15 additions & 0 deletions src/lib/site.ts
Original file line number Diff line number Diff line change
@@ -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();
}
18 changes: 17 additions & 1 deletion src/routes/+page.svelte
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
<script lang="ts">
import { onMount } from 'svelte';
import { absoluteUrl, siteMetadata } from '$lib/site';

const repo = 'AccountMap';
const demoUrl = 'https://demo.accountmap.org';
Expand All @@ -23,7 +24,22 @@
</script>

<svelte:head>
<title>AccountMap</title>
<title>{siteMetadata.title}</title>
<meta name="description" content={siteMetadata.description} />
<link rel="canonical" href={absoluteUrl('/')} />

<meta property="og:type" content="website" />
<meta property="og:site_name" content={siteMetadata.name} />
<meta property="og:locale" content={siteMetadata.locale} />
<meta property="og:title" content={siteMetadata.title} />
<meta property="og:description" content={siteMetadata.description} />
<meta property="og:url" content={absoluteUrl('/')} />
<meta property="og:image" content={absoluteUrl(siteMetadata.socialImagePath)} />

<meta name="twitter:card" content="summary_large_image" />
<meta name="twitter:title" content={siteMetadata.title} />
<meta name="twitter:description" content={siteMetadata.description} />
<meta name="twitter:image" content={absoluteUrl(siteMetadata.socialImagePath)} />
</svelte:head>

<main class="min-h-screen bg-[#0a0a0c] text-white selection:bg-cyan-500/30">
Expand Down
16 changes: 16 additions & 0 deletions src/routes/robots.txt/+server.ts
Original file line number Diff line number Diff line change
@@ -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'
}
});
37 changes: 37 additions & 0 deletions src/routes/sitemap.xml/+server.ts
Original file line number Diff line number Diff line change
@@ -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('&', '&amp;')
.replaceAll('<', '&lt;')
.replaceAll('>', '&gt;')
.replaceAll('"', '&quot;')
.replaceAll("'", '&apos;');
}

export const GET: RequestHandler = () => {
const urls = publicRoutes
.map(
(pathname) => ` <url>
<loc>${escapeXml(absoluteUrl(pathname))}</loc>
</url>`
)
.join('\n');

return new Response(
`<?xml version="1.0" encoding="UTF-8"?>
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
${urls}
</urlset>
`,
{
headers: {
'Content-Type': 'application/xml; charset=utf-8',
'Cache-Control': 'public, max-age=3600'
}
}
);
};
37 changes: 37 additions & 0 deletions tests/seo.test.mjs
Original file line number Diff line number Diff line change
@@ -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, /<title>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);
});