Skip to content

Commit

Permalink
feat: Add sitemap helper
Browse files Browse the repository at this point in the history
  • Loading branch information
kbkn3 committed Sep 6, 2024
1 parent c8d3643 commit c86686a
Show file tree
Hide file tree
Showing 3 changed files with 244 additions and 330 deletions.
91 changes: 47 additions & 44 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -817,65 +817,68 @@ 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:
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.
Expand Down
183 changes: 80 additions & 103 deletions src/vite/sitemap.test.ts
Original file line number Diff line number Diff line change
@@ -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('<?xml version="1.0" encoding="UTF-8"?>')
expect(result.data).toContain('<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">')
expect(result.data).toContain('<loc>https://example.com/</loc>')
expect(result.data).toContain('<loc>https://example.com/about/</loc>')
expect(result.data).toContain('<loc>https://example.com/contact/</loc>')
})

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('<loc>https://example.com/</loc>')
)
expect(fs.writeFileSync).toHaveBeenCalledWith(
resolve(process.cwd(), 'dist', 'sitemap.xml'),
expect.stringContaining('<loc>https://example.com/about/</loc>')
)
})
})
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('<loc>https://example.com/admin/</loc>')
})

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('<changefreq>daily</changefreq>')
expect(result.data).toContain('<priority>1.0</priority>')
})

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('<loc>http://localhost:5173/</loc>')
expect(result.data).toContain('<changefreq>weekly</changefreq>')
expect(result.data).toContain('<priority>0.5</priority>')
})
})
Loading

0 comments on commit c86686a

Please sign in to comment.