From c86686a1aed37d45c4431713a0d783fdb0a15818 Mon Sep 17 00:00:00 2001 From: Wabu-K Date: Fri, 6 Sep 2024 22:23:41 +0900 Subject: [PATCH] feat: Add sitemap helper --- README.md | 91 ++++++------ src/vite/sitemap.test.ts | 183 +++++++++++------------- src/vite/sitemap.ts | 300 +++++++++++++++------------------------ 3 files changed, 244 insertions(+), 330 deletions(-) diff --git a/README.md b/README.md index d75ebb3..0ababa1 100644 --- a/README.md +++ b/README.md @@ -817,31 +817,32 @@ export default defineConfig({ ### Generate Sitemap -To generate a sitemap, use the `sitemap` plugin provided by HonoX. - -Update your `vite.config.ts` as follows: +To generate a sitemap, use the `honox/dev/sitemap`. ```ts -import honox from 'honox/vite' -import adapter from '@hono/vite-dev-server/cloudflare' -import { defineConfig } from 'vite' -import sitemap from 'honox/vite/sitemap' - -export default defineConfig({ - plugins: [ - honox({ - devServer: { - adapter, - }, - }), - sitemap({ - hostname: 'https://example.com', - exclude: ['/404', 'error'], - priority: { '/': '1.0', '/about': '0.8', '/posts/*': '0.6' }, - frequency: { '/': 'daily', '/about': 'monthly', '/posts/*': 'weekly' }, - }), - ], +// app/routes/sitemap.xml.ts +import { Hono } from 'hono' +import { sitemap } from 'hono/vite/sitemap' +import app from '../server' + +const route = new Hono() + +route.get('/', async c => { + const { data , status, headers } = await sitemap({ + app, + hostname: 'https://example.com', + exclude: ['/hidden'], + priority: {'/': '1.0', '/posts/*': '0.6'}, + frequency: {'/': 'daily', '/posts/*': 'weekly'}, + }) + return c.body( + data, + status, + headers + ) }) + +export default route ``` For deployment to Cloudflare Pages, you can use the following configuration: @@ -849,33 +850,35 @@ For deployment to Cloudflare Pages, you can use the following configuration: Register the IS_PROD = true environment variable in the Cloudflare Pages settings: 1. Navigate to the Cloudflare Workers & Pages Dashboard. -1. Go to [Settings] -> [Environment Variables] -> [Production] -1. Add `IS_PROD` with the value `true`. +2. Go to [Settings] -> [Environment Variables] -> [Production] +3. Add `IS_PROD` with the value `true`. Update your `vite.config.ts`: ```ts -import pages from '@hono/vite-cloudflare-pages' -import honox from 'honox/vite' -import adapter from '@hono/vite-dev-server/cloudflare' -import { defineConfig } from 'vite' -import sitemap from 'honox/vite/sitemap' - -export default defineConfig({ - plugins: [ - honox({ - devServer: { - adapter, - }, - }), - pages(), - sitemap({ - hostname: process.env.IS_PROD - ? 'https://your-project-name.pages.dev/' - : process.env.CF_PAGES_URL, - }), - ], +// app/routes/sitemap.xml.ts +import { Hono } from 'hono' +import { sitemap } from 'hono/vite/sitemap' +import app from '../server' + +const route = new Hono() + +route.get('/', async c => { + const { data , status, headers } = await sitemap({ + app, + hostname: import.meta.env.IS_PROD ? 'https://example.com' : import.meta.env.CF_PAGES_URL, + exclude: ['/hidden'], + priority: {'/': '1.0', '/posts/*': '0.6'}, + frequency: {'/': 'daily', '/posts/*': 'weekly'}, + }) + return c.body( + data, + status, + headers + ) }) + +export default route ``` Note: `CF_PAGES_URL` is an environment variable that Cloudflare Pages automatically sets. diff --git a/src/vite/sitemap.test.ts b/src/vite/sitemap.test.ts index 9599f39..4026771 100644 --- a/src/vite/sitemap.test.ts +++ b/src/vite/sitemap.test.ts @@ -1,125 +1,102 @@ -import { resolve } from 'path' -import * as fs from 'fs' -import honoSitemapPlugin, { - getFrequency, - getPriority, - getValueForUrl, - isFilePathMatch, - processRoutes, - validateOptions, -} from './sitemap' - -vi.mock('fs', () => ({ - writeFileSync: vi.fn(), -})) - -describe('honoSitemapPlugin', () => { - beforeEach(() => { - vi.resetAllMocks() +import type { SitemapOptions } from './sitemap' +import sitemap from './sitemap' +import { Hono } from 'hono' + +// モックHonoアプリケーションを作成 +const createMockApp = (routes: string[]) => { + const app = new Hono() + routes.forEach((route) => { + app.get(route, () => new Response('OK')) }) + return app +} +describe('sitemap', () => { + it('sitemap generator creates valid XML', async () => { + const app = createMockApp(['/', '/about', '/contact']) + const options: SitemapOptions = { + app, + hostname: 'https://example.com', + } - it('should create a plugin with default options', () => { - const plugin = honoSitemapPlugin() - expect(plugin.name).toBe('vite-plugin-hono-sitemap') - expect(plugin.apply).toBe('build') - }) + const result = await sitemap(options) - it('should transform matching files', () => { - const plugin = honoSitemapPlugin() - // @ts-expect-error transform is private - const result = plugin.transform('', '/app/routes/index.tsx') - expect(result).toEqual({ code: '', map: null }) + expect(result.status).toBe(200) + expect(result.headers['Content-Type']).toBe('application/xml') + expect(result.data).toContain('') + expect(result.data).toContain('') + expect(result.data).toContain('https://example.com/') + expect(result.data).toContain('https://example.com/about/') + expect(result.data).toContain('https://example.com/contact/') }) - it('should generate sitemap on buildEnd', () => { - const plugin = honoSitemapPlugin({ hostname: 'https://example.com' }) - // @ts-expect-error transform is private - plugin.transform('', '/app/routes/index.tsx') - // @ts-expect-error transform is private - plugin.transform('', '/app/routes/about.tsx') - // @ts-expect-error buildEnd is private - plugin.buildEnd() - - expect(fs.writeFileSync).toHaveBeenCalledWith( - resolve(process.cwd(), 'dist', 'sitemap.xml'), - expect.stringContaining('https://example.com/') - ) - expect(fs.writeFileSync).toHaveBeenCalledWith( - resolve(process.cwd(), 'dist', 'sitemap.xml'), - expect.stringContaining('https://example.com/about/') - ) - }) -}) + it('sitemap generator respects exclude option', async () => { + const app = createMockApp(['/', '/about', '/contact', '/admin']) + const options: SitemapOptions = { + app, + hostname: 'https://example.com', + exclude: ['/admin'], + } -describe('isFilePathMatch', () => { - it('should match valid file paths', () => { - expect(isFilePathMatch('/Users/abc/repo/app/routes/index.tsx', [])).toBe(true) - expect(isFilePathMatch('/Users/abc/repo/app/routes/about/index.tsx', [])).toBe(true) - expect(isFilePathMatch('/Users/abc/repo/app/routes/.well-known/security.txt.tsx', [])).toBe( - true - ) - }) + const result = await sitemap(options) - it('should not match invalid file paths', () => { - expect(isFilePathMatch('/Users/abc/repo/app/routes/$id.tsx', [])).toBe(false) - expect(isFilePathMatch('/Users/abc/repo/app/routes/test.spec.tsx', [])).toBe(false) - expect(isFilePathMatch('/Users/abc/repo/app/routes/_middleware.tsx', [])).toBe(false) + expect(result.data).not.toContain('https://example.com/admin/') }) - it('should exclude specified paths', () => { - expect(isFilePathMatch('/Users/abc/repo/app/routes/admin/index.tsx', ['/admin'])).toBe(false) - }) -}) + it('sitemap generator uses custom frequency and priority', async () => { + const app = createMockApp(['/', '/about']) + const options: SitemapOptions = { + app, + hostname: 'https://example.com', + frequency: { + '/': 'daily', + }, + priority: { + '/': '1.0', + }, + } -describe('validateOptions', () => { - it('should throw error for invalid hostname', () => { - expect(() => validateOptions({ hostname: 'example.com' })).toThrow() - }) + const result = await sitemap(options) - it('should throw error for invalid priority', () => { - expect(() => validateOptions({ priority: { '/': '1.5' } })).toThrow() + expect(result.data).toContain('daily') + expect(result.data).toContain('1.0') }) - it('should throw error for invalid frequency', () => { - expect(() => validateOptions({ frequency: { '/': 'biweekly' as any } })).toThrow() - }) -}) + it('sitemap generator throws error for invalid priority', async () => { + const app = createMockApp(['/', '/about']) + const options: SitemapOptions = { + app, + hostname: 'https://example.com', + priority: { + '/': '2.0', // 無効な優先度 + }, + } -describe('processRoutes', () => { - it('should process routes correctly', () => { - const files = ['/app/routes/index.tsx', '/app/routes/about.tsx'] - const result = processRoutes(files, 'https://example.com', '/app/routes', {}, {}) - expect(result).toHaveLength(2) - expect(result[0].url).toBe('https://example.com') - expect(result[1].url).toBe('https://example.com/about') + await expect(sitemap(options)).rejects.toThrow('Invalid priority value') }) -}) -describe('getFrequency', () => { - it('should return correct frequency', () => { - expect(getFrequency('/', { '/': 'daily' })).toBe('daily') - expect(getFrequency('/about', { '/about': 'monthly' })).toBe('monthly') - expect(getFrequency('/unknown', {})).toBe('weekly') - }) -}) + it('sitemap generator throws error for invalid frequency', async () => { + const app = createMockApp(['/', '/about']) + const options: SitemapOptions = { + app, + hostname: 'https://example.com', + frequency: { + '/': 'annually' as never, // 無効な頻度 + }, + } -describe('getPriority', () => { - it('should return correct priority', () => { - expect(getPriority('/', { '/': '1.0' })).toBe('1.0') - expect(getPriority('/about', { '/about': '0.8' })).toBe('0.8') - expect(getPriority('/unknown', {})).toBe('0.5') + await expect(sitemap(options)).rejects.toThrow('Invalid frequency value') }) -}) -describe('getValueForUrl', () => { - it('should return correct value for URL patterns', () => { - const patterns = { - '/': 'home', - '/blog/*': 'blog', - '/about': 'about', + it('sitemap generator uses default values when not provided', async () => { + const app = createMockApp(['/', '/about']) + const options: SitemapOptions = { + app, } - expect(getValueForUrl('/', patterns, 'default')).toBe('home') - expect(getValueForUrl('/blog/post-1', patterns, 'default')).toBe('blog') - expect(getValueForUrl('/contact', patterns, 'default')).toBe('default') + + const result = await sitemap(options) + + expect(result.data).toContain('http://localhost:5173/') + expect(result.data).toContain('weekly') + expect(result.data).toContain('0.5') }) }) diff --git a/src/vite/sitemap.ts b/src/vite/sitemap.ts index e335033..694cc5a 100644 --- a/src/vite/sitemap.ts +++ b/src/vite/sitemap.ts @@ -1,146 +1,110 @@ -import type { Plugin, TransformResult } from 'vite' -import path, { resolve } from 'path' -import { existsSync, mkdirSync, writeFileSync } from 'fs' +import type { Hono } from 'hono' +import { inspectRoutes } from 'hono/dev' +import type { StatusCode } from 'hono/utils/http-status' + +interface RouteData { + path: string + method: string + name: string + isMiddleware: boolean +} -export type SitemapOptions = { +export interface SitemapOptions { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + app: Hono hostname?: string exclude?: string[] frequency?: Record priority?: Record - outputFileName?: string - routesDir?: string } -export const defaultOptions: SitemapOptions = { - hostname: 'localhost:5173', - exclude: [], - frequency: {}, - priority: {}, - outputFileName: 'sitemap.xml', - routesDir: '/app/routes', +interface SitemapResponse { + data: string + status: StatusCode + headers: Record } - type Frequency = 'always' | 'hourly' | 'daily' | 'weekly' | 'monthly' | 'yearly' | 'never' -const tsFiles: string[] = [] +const DEFAULT_CONFIG = { + hostname: 'http://localhost:5173', + exclude: ['/sitemap.xml'], + defaultFrequency: 'weekly' as Frequency, + defaultPriority: '0.5', +} /** - * Vite plugin to generate a sitemap.xml file. - * @param options - * @param {string} [options.hostname='localhost:5173'] - The hostname of the website. - * @param {string[]} [options.exclude=[]] - The list of files to exclude. - * @param {Record} [options.frequency] - The frequency of the pages. - * @param {Record} [options.priority] - The priority of the pages. - * @param {string} [options.outputFileName='sitemap.xml'] - The name of the output file. - * @param {string} [options.routesDir='/app/routes'] - The directory where the routes are located. - * @returns {Plugin} + * Generates a sitemap for the given Hono app. + * @param options - The options for generating the sitemap. + * @param options.app - The Hono app to generate the sitemap for. + * @param options.hostname - The hostname to use in the sitemap. Defaults to 'http://localhost:5173'. + * @param options.exclude - An array of paths to exclude from the sitemap. Defaults to ['/sitemap.xml']. + * @param options.frequency - An object mapping paths to their change frequency. Defaults to 'weekly'. + * @param options.priority - An object mapping paths to their priority. Defaults to '0.5'. + * @returns A promise that resolves to a SitemapResponse. + * @throws Error if options are invalid. * @example * ```ts - * import sitemap from 'honox/vite/sitemap' + * // app/routes/sitemap.xml.ts + * import { Hono } from 'hono' + * import { sitemap } from 'hono/vite/sitemap' + * import app from '../server' * - * export default defineConfig({ - * plugins: [ - * sitemap({ - * hostname: 'https://example.com', - * exclude: ['/secrets/*', '/user/*'], - * frequency: { '/': 'daily', '/about': 'monthly', '/posts/*': 'weekly' }, - * priority: { '/': '1.0', '/about': '0.8', '/posts/*': '0.5' }, - * }), - * ], + * const route = new Hono() + * + * route.get('/', async c => { + * const { data , status, headers } = await sitemap({ + * app, + * hostname: 'https://example.com', + * exclude: ['/hidden'], + * priority: {'/': '1.0', '/posts/*': '0.6'}, + * frequency: {'/': 'daily', '/posts/*': 'weekly'}, + * }) + * return c.body( + * data, + * status, + * headers + * ) * }) + * + * export default route * ``` */ -export function sitemap(options?: SitemapOptions): Plugin { - validateOptions(options) - const hostname = options?.hostname ?? defaultOptions.hostname ?? 'localhost:5173' - const exclude = options?.exclude ?? defaultOptions.exclude ?? [] - const frequency = options?.frequency ?? defaultOptions.frequency ?? {} - const priority = options?.priority ?? defaultOptions.priority ?? {} - const outputFileName = options?.outputFileName ?? defaultOptions.outputFileName ?? 'sitemap.xml' - const routesDir = options?.routesDir ?? defaultOptions.routesDir ?? '/app/routes' - - return { - name: 'vite-plugin-hono-sitemap', - apply: 'build', - transform(_code: string, id: string): TransformResult { - if (isFilePathMatch(id, exclude)) { - tsFiles.push(id) - } - return { code: _code, map: null } - }, - - buildEnd() { - const routes = processRoutes(tsFiles, hostname, routesDir, frequency, priority) - - const sitemap = ` - -${routes - .map( - (page) => ` - - ${page.url}/ - ${page.lastMod} - ${page.changeFreq} - ${page.priority} - -` - ) - .join('')} -` - - try { - const distPath = path.resolve(process.cwd(), 'dist') - // Create the dist directory if it doesn't exist - if (!existsSync(distPath)) { - mkdirSync(distPath, { recursive: true }) - } - writeFileSync(resolve(process.cwd(), 'dist', outputFileName), sitemap) - console.info(`Sitemap generated successfully: ${outputFileName}`) - } catch (error) { - console.error(`Failed to write sitemap file: ${error}`) - throw new Error(`Sitemap generation failed: ${error}`) - } - }, - } -} +const sitemap = async (options: SitemapOptions): Promise => { + try { + validateOptions(options) -/** - * Check if the file path matches the pattern. - * @param filePath - * @returns {boolean} - */ -export function isFilePathMatch(filePath: string, exclude: string[]): boolean { - const patterns = [ - '.*/app/routes/(?!.*(_|\\$|\\.test|\\.spec))[^/]+\\.(tsx|md|mdx)$', - '.*/app/routes/.+/(?!.*(_|\\$|\\.test|\\.spec))[^/]+\\.(tsx|md|mdx)$', - '.*/app/routes/\\.well-known/(?!.*(_|\\$|\\.test|\\.spec))[^/]+\\.(tsx|md|mdx)$', - ] - - const normalizedPath = path.normalize(filePath).replace(/\\/g, '/') - - // Check if the file is excluded - if (exclude.some((excludePath) => normalizedPath.includes(excludePath))) { - return false - } + const config = { ...DEFAULT_CONFIG, ...options } + const routesData: RouteData[] = inspectRoutes(config.app) - for (const pattern of patterns) { - const regex = new RegExp(`^${pattern}$`) - if (regex.test(normalizedPath)) { - return true - } - } + const filteredRoutes = sortRoutesByDepth(routesData).filter( + (route) => + !config.exclude.includes(route.path) && + route.method === 'GET' && + !route.isMiddleware && + route.path !== '/*' + ) - return false -} + const sitemapXml = await generateSitemapXml(filteredRoutes, config) -export function validateOptions(options?: SitemapOptions): void { - if (options === undefined) { - return - } - if (options.hostname && !/^(http:\/\/|https:\/\/)/.test(options.hostname)) { - throw new Error('hostname must start with http:// or https://') + return { + data: sitemapXml, + status: 200, + headers: { + 'Content-Type': 'application/xml', + }, + } + } catch (error) { + console.error('Error generating sitemap:', error) + throw error } +} +/** + * Validates the provided options. + * @param options - The options to validate. + * @throws Error if options are invalid. + */ +const validateOptions = (options: SitemapOptions): void => { if (options.priority) { for (const [key, value] of Object.entries(options.priority)) { const priority = Number.parseFloat(value) @@ -169,77 +133,47 @@ export function validateOptions(options?: SitemapOptions): void { } /** - * Process the routes. - * @param files - * @param hostname - * @param routesDir - * @param frequency - * @param priority - * @returns {Array<{ url: string; lastMod: string; changeFreq: string; priority: string }>} + * Sorts routes by the depth of their paths. + * @param routes - The routes to sort. + * @returns Sorted array of routes. */ -export function processRoutes( - files: string[], - hostname: string, - routesDir: string, - frequency: Record, - priority: Record -): { url: string; lastMod: string; changeFreq: string; priority: string }[] { - const modifiedHostname = hostname.endsWith('/') ? hostname.slice(0, -1) : hostname - return files.map((file) => { - const route = file.substring(file.indexOf(routesDir) + routesDir.length) - const withoutExtension = route.replace(/\.(tsx|mdx)$/, '') - const url = - withoutExtension === '/index' ? modifiedHostname : `${modifiedHostname}${withoutExtension}` - return { - url, - lastMod: new Date().toISOString(), - changeFreq: getFrequency(withoutExtension, frequency), - priority: getPriority(withoutExtension, priority), - } +const sortRoutesByDepth = (routes: RouteData[]): RouteData[] => { + return routes.sort((a, b) => { + const aDepth = a.path === '/' ? 0 : a.path.split('/').length + const bDepth = b.path === '/' ? 0 : b.path.split('/').length + return aDepth - bDepth }) } /** - * Get the frequency for a given URL. - * @param url - * @returns {string} + * Generates the XML content for the sitemap. + * @param routes - The filtered routes. + * @param config - The configuration options. + * @returns A promise that resolves to the XML string. */ -export function getFrequency(url: string, frequency: Record): string { - return getValueForUrl(url, frequency, 'weekly') -} - -/** - * Get the priority for a given URL. - * @param url - * @returns {string} - */ -export function getPriority(url: string, priority: Record): string { - return getValueForUrl(url, priority, '0.5') -} - -/** - * Get the value for a given URL based on patterns, checking from most specific to least specific. - * @param url - * @param patterns - * @param defaultValue - * @returns {string} - */ -export function getValueForUrl( - url: string, - patterns: Record, - defaultValue: string -): string { - // /index -> / - const urlWithoutIndex = url.replace(/\/index$/, '/') - const sortedPatterns = Object.entries(patterns).sort((a, b) => b[0].length - a[0].length) - - for (const [pattern, value] of sortedPatterns) { - if (new RegExp(`^${pattern.replace(/\*/g, '.*')}$`).test(urlWithoutIndex)) { - return value - } - } +const generateSitemapXml = async ( + routes: RouteData[], + config: SitemapOptions & typeof DEFAULT_CONFIG +): Promise => { + const lastMod = new Date().toISOString().split('T')[0] + const getChangeFreq = (path: string) => config.frequency?.[path] || config.defaultFrequency + const getPriority = (path: string) => config.priority?.[path] || config.defaultPriority + + const urlEntries = routes.map( + (route) => ` + + ${route.path === '/' ? config.hostname : `${config.hostname}${route.path}`}/ + ${lastMod} + ${getChangeFreq(route.path)} + ${getPriority(route.path)} + + ` + ) - return defaultValue + return ` + + ${urlEntries.join('')} + ` } export default sitemap