Skip to content

Commit

Permalink
feat: Add sitemap plugin
Browse files Browse the repository at this point in the history
  • Loading branch information
kbkn3 committed Aug 26, 2024
1 parent 8ef0918 commit 6b5d028
Show file tree
Hide file tree
Showing 3 changed files with 372 additions and 0 deletions.
7 changes: 7 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -91,6 +95,9 @@
"vite/client": [
"./dist/vite/client"
],
"vite/sitemap": [
"./dist/vite/sitemap"
],
"vite/components": [
"./dist/vite/components"
]
Expand Down
125 changes: 125 additions & 0 deletions src/vite/sitemap.test.ts
Original file line number Diff line number Diff line change
@@ -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('<loc>https://example.com/</loc>')
)
expect(fs.writeFileSync).toHaveBeenCalledWith(
resolve(process.cwd(), 'dist', 'sitemap.xml'),
expect.stringContaining('<loc>https://example.com/about/</loc>')
)
})
})

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')
})
})
240 changes: 240 additions & 0 deletions src/vite/sitemap.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,240 @@
import type { Plugin, TransformResult } from 'vite'
import path, { resolve } from 'path'
import { writeFileSync } from 'fs'

export type SitemapOptions = {
hostname?: string
exclude?: string[]
frequency?: Record<string, Frequency>
priority?: Record<string, string>
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<string, string>} [options.frequency] - The frequency of the pages.
* @param {Record<string, string>} [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 = `<?xml version="1.0" encoding="UTF-8"?>
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
${routes
.map(
(page) => `
<url>
<loc>${page.url}/</loc>
<lastmod>${page.lastMod}</lastmod>
<changefreq>${page.changeFreq}</changefreq>
<priority>${page.priority}</priority>
</url>
`
)
.join('')}
</urlset>`

try {
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<string, Frequency>,
priority: Record<string, string>
): { 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, string>): 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, string>): 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<string, string>,
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

0 comments on commit 6b5d028

Please sign in to comment.