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