From 6b5d0284990b20950e16c3fe310094a5318a7f58 Mon Sep 17 00:00:00 2001 From: Wabu-K Date: Mon, 26 Aug 2024 22:40:47 +0900 Subject: [PATCH] feat: Add sitemap plugin --- package.json | 7 ++ src/vite/sitemap.test.ts | 125 ++++++++++++++++++++ src/vite/sitemap.ts | 240 +++++++++++++++++++++++++++++++++++++++ 3 files changed, 372 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..9a8b85f --- /dev/null +++ b/src/vite/sitemap.ts @@ -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 + 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 { + 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