diff --git a/README.md b/README.md index bdbc858..0ababa1 100644 --- a/README.md +++ b/README.md @@ -505,18 +505,18 @@ export default defineConfig(({ mode }) => { output: { entryFileNames: 'static/client.js', chunkFileNames: 'static/assets/[name]-[hash].js', - assetFileNames: 'static/assets/[name].[ext]' - } + assetFileNames: 'static/assets/[name].[ext]', + }, }, - emptyOutDir: false - } + emptyOutDir: false, + }, } } else { return { ssr: { - external: ['react', 'react-dom'] + external: ['react', 'react-dom'], }, - plugins: [honox(), pages()] + plugins: [honox(), pages()], } } }) @@ -815,6 +815,75 @@ export default defineConfig({ }) ``` +### Generate Sitemap + +To generate a sitemap, use the `honox/dev/sitemap`. + +```ts +// 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: + +Register the IS_PROD = true environment variable in the Cloudflare Pages settings: + +1. Navigate to the Cloudflare Workers & Pages Dashboard. +2. Go to [Settings] -> [Environment Variables] -> [Production] +3. Add `IS_PROD` with the value `true`. + +Update your `vite.config.ts`: + +```ts +// 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. +For more information, see [Environment Variables](https://developers.cloudflare.com/pages/configuration/build-configuration/#environment-variables). + ## Deployment Since a HonoX instance is essentially a Hono instance, it can be deployed on any platform that Hono supports. diff --git a/package.json b/package.json index 43fb627..9d28ed2 100644 --- a/package.json +++ b/package.json @@ -60,6 +60,10 @@ "types": "./dist/vite/client.d.ts", "import": "./dist/vite/client.js" }, + "./vite/sitemap": { + "types": "./dist/vite/sitemap.d.ts", + "import": "./dist/vite/sitemap.js" + }, "./vite/components": { "types": "./dist/vite/components/index.d.ts", "import": "./dist/vite/components/index.js" @@ -91,6 +95,9 @@ "vite/client": [ "./dist/vite/client" ], + "vite/sitemap": [ + "./dist/vite/sitemap" + ], "vite/components": [ "./dist/vite/components" ] diff --git a/src/vite/sitemap.test.ts b/src/vite/sitemap.test.ts new file mode 100644 index 0000000..4026771 --- /dev/null +++ b/src/vite/sitemap.test.ts @@ -0,0 +1,102 @@ +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', + } + + const result = await sitemap(options) + + 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('sitemap generator respects exclude option', async () => { + const app = createMockApp(['/', '/about', '/contact', '/admin']) + const options: SitemapOptions = { + app, + hostname: 'https://example.com', + exclude: ['/admin'], + } + + const result = await sitemap(options) + + expect(result.data).not.toContain('https://example.com/admin/') + }) + + 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', + }, + } + + const result = await sitemap(options) + + expect(result.data).toContain('daily') + expect(result.data).toContain('1.0') + }) + + it('sitemap generator throws error for invalid priority', async () => { + const app = createMockApp(['/', '/about']) + const options: SitemapOptions = { + app, + hostname: 'https://example.com', + priority: { + '/': '2.0', // 無効な優先度 + }, + } + + await expect(sitemap(options)).rejects.toThrow('Invalid priority value') + }) + + 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, // 無効な頻度 + }, + } + + await expect(sitemap(options)).rejects.toThrow('Invalid frequency value') + }) + + it('sitemap generator uses default values when not provided', async () => { + const app = createMockApp(['/', '/about']) + const options: SitemapOptions = { + app, + } + + 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 new file mode 100644 index 0000000..694cc5a --- /dev/null +++ b/src/vite/sitemap.ts @@ -0,0 +1,179 @@ +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 interface SitemapOptions { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + app: Hono + hostname?: string + exclude?: string[] + frequency?: Record + priority?: Record +} + +interface SitemapResponse { + data: string + status: StatusCode + headers: Record +} +type Frequency = 'always' | 'hourly' | 'daily' | 'weekly' | 'monthly' | 'yearly' | 'never' + +const DEFAULT_CONFIG = { + hostname: 'http://localhost:5173', + exclude: ['/sitemap.xml'], + defaultFrequency: 'weekly' as Frequency, + defaultPriority: '0.5', +} + +/** + * 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 + * // 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 + * ``` + */ +const sitemap = async (options: SitemapOptions): Promise => { + try { + validateOptions(options) + + const config = { ...DEFAULT_CONFIG, ...options } + const routesData: RouteData[] = inspectRoutes(config.app) + + const filteredRoutes = sortRoutesByDepth(routesData).filter( + (route) => + !config.exclude.includes(route.path) && + route.method === 'GET' && + !route.isMiddleware && + route.path !== '/*' + ) + + const sitemapXml = await generateSitemapXml(filteredRoutes, config) + + 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) + if (Number.isNaN(priority) || priority < 0 || priority > 1) { + throw new Error(`Invalid priority value for ${key}: ${value}. Must be between 0.0 and 1.0`) + } + } + } + + if (options.frequency) { + const validFrequencies: Frequency[] = [ + 'always', + 'hourly', + 'daily', + 'weekly', + 'monthly', + 'yearly', + 'never', + ] + for (const [key, value] of Object.entries(options.frequency)) { + if (!validFrequencies.includes(value)) { + throw new Error(`Invalid frequency value for ${key}: ${value}`) + } + } + } +} + +/** + * Sorts routes by the depth of their paths. + * @param routes - The routes to sort. + * @returns Sorted array of routes. + */ +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 + }) +} + +/** + * 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. + */ +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 ` + + ${urlEntries.join('')} + ` +} + +export default sitemap