-
Notifications
You must be signed in to change notification settings - Fork 46
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
- Loading branch information
Showing
3 changed files
with
372 additions
and
0 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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') | ||
}) | ||
}) |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 |