diff --git a/app/lib/shared/sitemap.test.ts b/app/lib/shared/sitemap.test.ts new file mode 100644 index 0000000..5080eff --- /dev/null +++ b/app/lib/shared/sitemap.test.ts @@ -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("https://example.com/"); + expect(xml).toContain("https://example.com/login"); + }); + + it("XML の特殊文字をエスケープする", () => { + expect(buildSitemapXml("https://example.com", ["/a?x=1&y=2"])).toContain( + "https://example.com/a?x=1&y=2", + ); + }); +}); + +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"); + }); +}); diff --git a/app/lib/shared/sitemap.ts b/app/lib/shared/sitemap.ts new file mode 100644 index 0000000..1cb3668 --- /dev/null +++ b/app/lib/shared/sitemap.ts @@ -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, "&") + .replace(//g, ">") + .replace(/"/g, """) + .replace(/'/g, "'"); +} + +// origin(例: https://example.com)と パスの一覧から sitemap.xml の本文を返す +export function buildSitemapXml(origin: string, paths: readonly string[]): string { + const urls = paths + .map((path) => ` ${escapeXml(new URL(path, origin).toString())}`) + .join("\n"); + return ` + +${urls} + +`; +} + +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()} +`; +} diff --git a/app/routes/robots[.]txt/route.ts b/app/routes/robots[.]txt/route.ts new file mode 100644 index 0000000..e699ac0 --- /dev/null +++ b/app/routes/robots[.]txt/route.ts @@ -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", + }, + }); +} diff --git a/app/routes/sitemap[.]xml/route.ts b/app/routes/sitemap[.]xml/route.ts new file mode 100644 index 0000000..72a0c47 --- /dev/null +++ b/app/routes/sitemap[.]xml/route.ts @@ -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", + }, + }); +}