diff --git a/src/app/sitemap.ts b/src/app/sitemap.ts index 134b386..3955e2a 100644 --- a/src/app/sitemap.ts +++ b/src/app/sitemap.ts @@ -1,13 +1,7 @@ -import { BASE_URL } from '@/lib/constants'; import { type MetadataRoute } from 'next'; -export default function sitemap(): MetadataRoute.Sitemap { - return [ - { - url: BASE_URL, - lastModified: new Date(), - changeFrequency: 'yearly', - priority: 1, - }, - ]; +import { buildSitemapEntries } from '@/lib/sitemap'; + +export default async function sitemap(): Promise { + return buildSitemapEntries(); } diff --git a/src/lib/__tests__/sitemap.test.ts b/src/lib/__tests__/sitemap.test.ts new file mode 100644 index 0000000..ecdbb32 --- /dev/null +++ b/src/lib/__tests__/sitemap.test.ts @@ -0,0 +1,23 @@ +import { describe, expect, it } from 'vitest'; + +import { buildSitemapEntries } from '../sitemap'; + +describe('buildSitemapEntries', () => { + it('includes known static routes', async () => { + const sitemap = await buildSitemapEntries(); + const urls = sitemap.map((entry) => entry.url); + + expect(urls).toContain('https://investingfire.com/'); + expect(urls).toContain('https://investingfire.com/learn'); + expect(urls).toContain('https://investingfire.com/learn/what-is-fire'); + expect(sitemap.every((entry) => entry.lastModified instanceof Date)).toBe(true); + }); + + it('omits metadata routes from the sitemap output', async () => { + const sitemap = await buildSitemapEntries(); + const urls = sitemap.map((entry) => entry.url); + + expect(urls.some((url) => url.includes('sitemap'))).toBe(false); + expect(urls.some((url) => url.includes('robots'))).toBe(false); + }); +}); diff --git a/src/lib/sitemap.ts b/src/lib/sitemap.ts new file mode 100644 index 0000000..c49640a --- /dev/null +++ b/src/lib/sitemap.ts @@ -0,0 +1,75 @@ +import { promises as fs } from 'fs'; +import path from 'path'; +import { type MetadataRoute } from 'next'; + +import { BASE_URL } from '@/lib/constants'; + +interface PageRoute { + pathname: string; + lastModified: Date; +} + +const PAGE_FILE_PATTERN = /^page\.(mdx|tsx?|jsx?)$/; +const EXCLUDED_DIRECTORIES = new Set(['components', '__tests__', 'api']); +const APP_DIR = path.join(process.cwd(), 'src', 'app'); + +const isRouteGroup = (name: string) => name.startsWith('(') && name.endsWith(')'); +const shouldSkipDirectory = (name: string) => + EXCLUDED_DIRECTORIES.has(name) || name.startsWith('_') || name.startsWith('.') || name.includes('['); + +async function discoverPages(currentDir: string, segments: string[] = []): Promise { + const entries = await fs.readdir(currentDir, { withFileTypes: true }); + const pages: PageRoute[] = []; + + for (const entry of entries) { + const entryPath = path.join(currentDir, entry.name); + + if (entry.isDirectory()) { + if (shouldSkipDirectory(entry.name)) { + continue; + } + + const nextSegments = isRouteGroup(entry.name) ? segments : [...segments, entry.name]; + const childPages = await discoverPages(entryPath, nextSegments); + pages.push(...childPages); + continue; + } + + if (entry.isFile() && PAGE_FILE_PATTERN.test(entry.name)) { + const pathname = segments.length === 0 ? '/' : `/${segments.join('/')}`; + const stats = await fs.stat(entryPath); + + pages.push({ pathname, lastModified: stats.mtime }); + } + } + + return pages; +} + +function toAbsoluteUrl(pathname: string): string { + const normalized = pathname === '/' ? '' : pathname; + return new URL(normalized, BASE_URL).toString(); +} + +export async function buildSitemapEntries(): Promise { + const pages = await discoverPages(APP_DIR); + + const uniquePages = new Map(); + for (const page of pages) { + const existing = uniquePages.get(page.pathname); + if (!existing || existing.lastModified < page.lastModified) { + uniquePages.set(page.pathname, page); + } + } + + const sortedPages = Array.from(uniquePages.values()).sort((a, b) => + a.pathname.localeCompare(b.pathname), + ); + + return sortedPages.map(({ pathname, lastModified }) => ({ + url: toAbsoluteUrl(pathname), + lastModified, + changeFrequency: 'weekly', + priority: pathname === '/' ? 1 : 0.8, + })); +}