Skip to content
Merged
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
25 changes: 25 additions & 0 deletions app/lib/shared/sitemap.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import { describe, expect, it } from "vite-plus/test";

import { buildRobotsTxt, buildSitemapXml } from "./sitemap";

describe("buildSitemapXml", () => {
it("パスを origin つきの絶対URLにして loc に入れる", () => {
const xml = buildSitemapXml("https://example.com", ["/", "/login"]);
expect(xml).toContain("<loc>https://example.com/</loc>");
expect(xml).toContain("<loc>https://example.com/login</loc>");
});

it("XML の特殊文字をエスケープする", () => {
expect(buildSitemapXml("https://example.com", ["/a?x=1&y=2"])).toContain(
"<loc>https://example.com/a?x=1&amp;y=2</loc>",
);
});
});

describe("buildRobotsTxt", () => {
it("Disallow と sitemap の絶対URLを書く", () => {
const txt = buildRobotsTxt("https://example.com", ["/api/"]);
expect(txt).toContain("Disallow: /api/");
expect(txt).toContain("Sitemap: https://example.com/sitemap.xml");
});
});
42 changes: 42 additions & 0 deletions app/lib/shared/sitemap.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
// 検索エンジン(Googleのクローラなど)に渡す sitemap.xml・robots.txt を組み立てる。
// /learn や /my-notebooks などはログイン必須でクローラが読めないため、未ログインで見られるページだけを載せる
export const SITEMAP_PATHS = ["/", "/login", "/signup"] as const;

// クローラに辿らせないパス。API とログイン必須のページ
export const ROBOTS_DISALLOW_PATHS = [
"/api/",
"/history",
"/learn",
"/my-notebooks",
"/invite/",
] as const;

function escapeXml(value: string): string {
return value
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&apos;");
}

// origin(例: https://example.com)と パスの一覧から sitemap.xml の本文を返す
export function buildSitemapXml(origin: string, paths: readonly string[]): string {
const urls = paths
.map((path) => ` <url><loc>${escapeXml(new URL(path, origin).toString())}</loc></url>`)
.join("\n");
return `<?xml version="1.0" encoding="UTF-8"?>
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
${urls}
</urlset>
`;
}

export function buildRobotsTxt(origin: string, disallowPaths: readonly string[]): string {
const disallow = disallowPaths.map((path) => `Disallow: ${path}`).join("\n");
return `User-agent: *
${disallow}

Sitemap: ${new URL("/sitemap.xml", origin).toString()}
`;
}
15 changes: 15 additions & 0 deletions app/routes/robots[.]txt/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import { buildRobotsTxt, ROBOTS_DISALLOW_PATHS } from "@/lib/shared/sitemap";

// 検索エンジン向けの /robots.txt。sitemap.xml の場所もここで知らせる
export function loader() {
const txt = buildRobotsTxt(
process.env.BETTER_AUTH_URL ?? "http://localhost:3000",
ROBOTS_DISALLOW_PATHS,
);
return new Response(txt, {
headers: {
"Content-Type": "text/plain; charset=utf-8",
"Cache-Control": "public, max-age=3600",
},
});
}
15 changes: 15 additions & 0 deletions app/routes/sitemap[.]xml/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import { buildSitemapXml, SITEMAP_PATHS } from "@/lib/shared/sitemap";

// 検索エンジン向けの /sitemap.xml。アプリのオリジンは認証と同じ環境変数を使う
export function loader() {
const xml = buildSitemapXml(
process.env.BETTER_AUTH_URL ?? "http://localhost:3000",
SITEMAP_PATHS,
);
return new Response(xml, {
headers: {
"Content-Type": "application/xml; charset=utf-8",
"Cache-Control": "public, max-age=3600",
},
});
}
Loading