diff --git a/lib/registry.dynamic.test.ts b/lib/registry.dynamic.test.ts index 940ccf32e..81ca7f748 100644 --- a/lib/registry.dynamic.test.ts +++ b/lib/registry.dynamic.test.ts @@ -124,4 +124,80 @@ describe('registry dynamic loading', () => { process.env.NODE_ENV = originalEnv; }); + + // https://github.com/DIYgod/RSSHub/pull/18002 + it('prioritizes literal segments over parameter segments in route matching', async () => { + const originalEnv = process.env.NODE_ENV; + process.env.NODE_ENV = 'development'; + + const modules = { + '/specificity/param-route.ts': { + route: { + path: '/:category', + name: 'ParamRoute', + handler: () => ({ + title: 'param', + link: 'https://example.com', + item: [], + allowEmpty: true, + }), + }, + }, + '/specificity/literal-route.ts': { + route: { + path: '/news/:channel', + name: 'LiteralRoute', + handler: () => ({ + title: 'literal', + link: 'https://example.com', + item: [], + allowEmpty: true, + }), + }, + }, + '/specificity/news-route.ts': { + route: { + path: '/news', + name: 'NewsRoute', + handler: () => ({ + title: 'news', + link: 'https://example.com', + item: [], + allowEmpty: true, + }), + }, + }, + }; + + const directoryImportMock = vi.fn(() => modules); + vi.doMock('@/utils/directory-import', () => ({ + directoryImport: directoryImportMock, + })); + vi.resetModules(); + const { default: registry } = await import('@/registry'); + + const app = new Hono(); + app.use(async (ctx, next) => { + await next(); + const data = ctx.get('data'); + if (data) { + return ctx.json(data); + } + }); + app.route('/', registry); + + // /news/sports should match /news/:channel (literal "news" wins over :category) + const literalResponse = await app.request('/specificity/news/sports'); + expect((await literalResponse.json()).title).toBe('literal'); + + // /news should match /news (literal wins over :category) + const newsResponse = await app.request('/specificity/news'); + expect((await newsResponse.json()).title).toBe('news'); + + // /products should match /:category + const paramResponse = await app.request('/specificity/products'); + expect((await paramResponse.json()).title).toBe('param'); + + process.env.NODE_ENV = originalEnv; + }); }); diff --git a/lib/registry.ts b/lib/registry.ts index 521cd6c20..414dfb5d1 100644 --- a/lib/registry.ts +++ b/lib/registry.ts @@ -72,10 +72,10 @@ if (config.isPackage) { } break; default: - modules = directoryImport({ + modules = (await directoryImport({ targetDirectoryPath: path.join(__dirname, './routes'), importPattern: /\.tsx?$/, - }) as typeof modules; + })) as typeof modules; } } @@ -100,6 +100,7 @@ if (Object.keys(modules).length) { namespaces[namespace] = Object.assign( { routes: {}, + apiRoutes: {}, }, namespaces[namespace], content.namespace diff --git a/lib/utils/directory-import.test.ts b/lib/utils/directory-import.test.ts index d08ec0cc2..1cd569cca 100644 --- a/lib/utils/directory-import.test.ts +++ b/lib/utils/directory-import.test.ts @@ -23,21 +23,21 @@ describe('directory-import', () => { } }); - it('imports valid files and skips invalid ones', () => { + it('imports valid files and skips invalid ones', async () => { tempDir = createTempDir(); - const rootModule = path.join(tempDir, 'valid.cjs'); + const rootModule = path.join(tempDir, 'valid.mjs'); const jsonModule = path.join(tempDir, 'data.json'); const ignoredText = path.join(tempDir, 'note.txt'); const declaration = path.join(tempDir, 'types.d.ts'); - const nestedModule = path.join(tempDir, 'sub', 'child.cjs'); + const nestedModule = path.join(tempDir, 'sub', 'child.mjs'); - writeFile(rootModule, "module.exports = { value: 'root' };"); + writeFile(rootModule, "export const value = 'root';"); writeFile(jsonModule, JSON.stringify({ value: 'json' })); writeFile(ignoredText, 'ignore'); writeFile(declaration, 'export {};'); - writeFile(nestedModule, "module.exports = { value: 'child' };"); + writeFile(nestedModule, "export const value = 'child';"); - const modules = directoryImport({ targetDirectoryPath: tempDir }); + const modules = await directoryImport({ targetDirectoryPath: tempDir }); const keyFor = (filePath: string) => filePath.slice(tempDir.length); expect(modules).toHaveProperty(keyFor(rootModule)); @@ -47,15 +47,15 @@ describe('directory-import', () => { expect(modules).not.toHaveProperty(keyFor(declaration)); }); - it('can skip subdirectories and apply patterns', () => { + it('can skip subdirectories and apply patterns', async () => { tempDir = createTempDir(); - const rootModule = path.join(tempDir, 'keep.cjs'); - const nestedModule = path.join(tempDir, 'sub', 'skip.cjs'); + const rootModule = path.join(tempDir, 'keep.mjs'); + const nestedModule = path.join(tempDir, 'sub', 'skip.mjs'); - writeFile(rootModule, "module.exports = { value: 'keep' };"); - writeFile(nestedModule, "module.exports = { value: 'skip' };"); + writeFile(rootModule, "export const value = 'keep';"); + writeFile(nestedModule, "export const value = 'skip';"); - const modules = directoryImport({ + const modules = await directoryImport({ targetDirectoryPath: tempDir, includeSubdirectories: false, importPattern: /keep/, diff --git a/lib/utils/directory-import.ts b/lib/utils/directory-import.ts index e2a4c10b6..abf00b411 100644 --- a/lib/utils/directory-import.ts +++ b/lib/utils/directory-import.ts @@ -1,8 +1,6 @@ import fs from 'node:fs'; -import { createRequire } from 'node:module'; import path from 'node:path'; - -const require = createRequire(import.meta.url); +import { pathToFileURL } from 'node:url'; export type DirectoryImportOptions = { targetDirectoryPath: string; @@ -33,23 +31,25 @@ const readDirectory = (targetDirectoryPath: string, includeSubdirectories: boole return files; }; -export const directoryImport = ({ targetDirectoryPath, importPattern = /.*/, includeSubdirectories = true }: DirectoryImportOptions) => { +export const directoryImport = async ({ targetDirectoryPath, importPattern = /.*/, includeSubdirectories = true }: DirectoryImportOptions) => { const modules: Record = {}; const filesPaths = readDirectory(targetDirectoryPath, includeSubdirectories); - for (const filePath of filesPaths) { - const { ext: fileExtension } = path.parse(filePath); - const isValidModuleExtension = VALID_IMPORT_EXTENSIONS.has(fileExtension); - const isDeclarationFile = filePath.endsWith('.d.ts') || filePath.endsWith('.d.tsx'); - const isValidFilePath = importPattern.test(filePath); + await Promise.all( + filesPaths.map(async (filePath) => { + const { ext: fileExtension } = path.parse(filePath); + const isValidModuleExtension = VALID_IMPORT_EXTENSIONS.has(fileExtension); + const isDeclarationFile = filePath.endsWith('.d.ts') || filePath.endsWith('.d.tsx'); + const isValidFilePath = importPattern.test(filePath); - if (!isValidModuleExtension || isDeclarationFile || !isValidFilePath) { - continue; - } + if (!isValidModuleExtension || isDeclarationFile || !isValidFilePath) { + return; + } - const relativeModulePath = filePath.slice(targetDirectoryPath.length); - modules[relativeModulePath] = require(filePath); - } + const relativeModulePath = filePath.slice(targetDirectoryPath.length); + modules[relativeModulePath] = await import(pathToFileURL(filePath).href); + }) + ); return modules; };