From f44e245b1d1d4f7d20c61d688bf3231b6cd15f50 Mon Sep 17 00:00:00 2001 From: Wabu-K Date: Mon, 26 Aug 2024 22:40:47 +0900 Subject: [PATCH 1/3] feat: Add sitemap plugin --- package.json | 7 ++ src/vite/sitemap.test.ts | 125 ++++++++++++++++++++ src/vite/sitemap.ts | 245 +++++++++++++++++++++++++++++++++++++++ 3 files changed, 377 insertions(+) create mode 100644 src/vite/sitemap.test.ts create mode 100644 src/vite/sitemap.ts 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..9599f39 --- /dev/null +++ b/src/vite/sitemap.test.ts @@ -0,0 +1,125 @@ +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() + }) + + it('should create a plugin with default options', () => { + const plugin = honoSitemapPlugin() + expect(plugin.name).toBe('vite-plugin-hono-sitemap') + expect(plugin.apply).toBe('build') + }) + + 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 }) + }) + + 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/') + ) + }) +}) + +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 + ) + }) + + 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) + }) + + it('should exclude specified paths', () => { + expect(isFilePathMatch('/Users/abc/repo/app/routes/admin/index.tsx', ['/admin'])).toBe(false) + }) +}) + +describe('validateOptions', () => { + it('should throw error for invalid hostname', () => { + expect(() => validateOptions({ hostname: 'example.com' })).toThrow() + }) + + it('should throw error for invalid priority', () => { + expect(() => validateOptions({ priority: { '/': '1.5' } })).toThrow() + }) + + it('should throw error for invalid frequency', () => { + expect(() => validateOptions({ frequency: { '/': 'biweekly' as any } })).toThrow() + }) +}) + +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') + }) +}) + +describe('getFrequency', () => { + it('should return correct frequency', () => { + expect(getFrequency('/', { '/': 'daily' })).toBe('daily') + expect(getFrequency('/about', { '/about': 'monthly' })).toBe('monthly') + expect(getFrequency('/unknown', {})).toBe('weekly') + }) +}) + +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') + }) +}) + +describe('getValueForUrl', () => { + it('should return correct value for URL patterns', () => { + const patterns = { + '/': 'home', + '/blog/*': 'blog', + '/about': 'about', + } + expect(getValueForUrl('/', patterns, 'default')).toBe('home') + expect(getValueForUrl('/blog/post-1', patterns, 'default')).toBe('blog') + expect(getValueForUrl('/contact', patterns, 'default')).toBe('default') + }) +}) diff --git a/src/vite/sitemap.ts b/src/vite/sitemap.ts new file mode 100644 index 0000000..e335033 --- /dev/null +++ b/src/vite/sitemap.ts @@ -0,0 +1,245 @@ +import type { Plugin, TransformResult } from 'vite' +import path, { resolve } from 'path' +import { existsSync, mkdirSync, writeFileSync } from 'fs' + +export type SitemapOptions = { + 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', +} + +type Frequency = 'always' | 'hourly' | 'daily' | 'weekly' | 'monthly' | 'yearly' | 'never' + +const tsFiles: string[] = [] + +/** + * 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} + * @example + * ```ts + * import sitemap from 'honox/vite/sitemap' + * + * 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' }, + * }), + * ], + * }) + * ``` + */ +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}`) + } + }, + } +} + +/** + * 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 + } + + for (const pattern of patterns) { + const regex = new RegExp(`^${pattern}$`) + if (regex.test(normalizedPath)) { + return true + } + } + + return false +} + +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://') + } + + 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}`) + } + } + } +} + +/** + * Process the routes. + * @param files + * @param hostname + * @param routesDir + * @param frequency + * @param priority + * @returns {Array<{ url: string; lastMod: string; changeFreq: string; priority: string }>} + */ +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), + } + }) +} + +/** + * Get the frequency for a given URL. + * @param url + * @returns {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 + } + } + + return defaultValue +} + +export default sitemap From c8d3643ed74352690d3336f480d752d63e9315e1 Mon Sep 17 00:00:00 2001 From: Wabu-K Date: Sun, 1 Sep 2024 00:49:13 +0900 Subject: [PATCH 2/3] docs(README.md): Update sitemap generation configuration The commit adds the sitemap plugin and updates the `vite.config.ts` file to include the necessary configuration for generating a sitemap. It also mentions the usage of the `honox` plugin and the `pages` plugin for Cloudflare Pages deployment. --- README.md | 78 ++++++++++++++++++++++++++++++++++++++++++++++++++----- 1 file changed, 72 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index bdbc858..d75ebb3 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,72 @@ export default defineConfig({ }) ``` +### Generate Sitemap + +To generate a sitemap, use the `sitemap` plugin provided by HonoX. + +Update your `vite.config.ts` as follows: + +```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' }, + }), + ], +}) +``` + +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`. + +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, + }), + ], +}) +``` + +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. From c86686a1aed37d45c4431713a0d783fdb0a15818 Mon Sep 17 00:00:00 2001 From: Wabu-K Date: Fri, 6 Sep 2024 22:23:41 +0900 Subject: [PATCH 3/3] 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