diff --git a/packages/shared/src/utils/index.ts b/packages/shared/src/utils/index.ts index a14fe7c3d9..ad1157637d 100644 --- a/packages/shared/src/utils/index.ts +++ b/packages/shared/src/utils/index.ts @@ -2,6 +2,7 @@ export * from './dedupeHead.js' export * from './ensureLeadingSlash.js' export * from './ensureEndingSlash.js' export * from './formatDateString.js' +export * from './inferRoutePath.js' export * from './isLinkExternal.js' export * from './isLinkHttp.js' export * from './isLinkWithProtocol.js' diff --git a/packages/shared/src/utils/inferRoutePath.ts b/packages/shared/src/utils/inferRoutePath.ts new file mode 100644 index 0000000000..0a1ba2e4aa --- /dev/null +++ b/packages/shared/src/utils/inferRoutePath.ts @@ -0,0 +1,26 @@ +/** + * Infer route path according to the given (markdown file) path + */ +export const inferRoutePath = (path: string): string => { + // if the pathname is empty or ends with `/`, return as is + if (!path || path.endsWith('/')) return path + + // convert README.md to index.html + let routePath = path.replace(/(^|\/)README.md$/i, '$1index.html') + + // convert /foo/bar.md to /foo/bar.html + if (routePath.endsWith('.md')) { + routePath = routePath.substring(0, routePath.length - 3) + '.html' + } + // convert /foo/bar to /foo/bar.html + else if (!routePath.endsWith('.html')) { + routePath = routePath + '.html' + } + + // convert /foo/index.html to /foo/ + if (routePath.endsWith('/index.html')) { + routePath = routePath.substring(0, routePath.length - 10) + } + + return routePath +} diff --git a/packages/shared/src/utils/normalizeRoutePath.ts b/packages/shared/src/utils/normalizeRoutePath.ts index db2e72837d..334fc308c1 100644 --- a/packages/shared/src/utils/normalizeRoutePath.ts +++ b/packages/shared/src/utils/normalizeRoutePath.ts @@ -1,30 +1,21 @@ +import { inferRoutePath } from './inferRoutePath.js' + +const FAKE_HOST = 'http://.' + /** * Normalize the given path to the final route path */ -export const normalizeRoutePath = (path: string): string => { - // split pathname and query/hash - const [pathname, ...queryAndHash] = path.split(/(\?|#)/) - - // if the pathname is empty or ends with `/`, return as is - if (!pathname || pathname.endsWith('/')) return path +export const normalizeRoutePath = (path: string, current?: string): string => { + if (!path.startsWith('/') && current) { + // the relative path should be resolved against the current path + const loc = current.slice(0, current.lastIndexOf('/')) - // convert README.md to index.html - let routePath = pathname.replace(/(^|\/)README.md$/i, '$1index.html') + const { pathname, search, hash } = new URL(`${loc}/${path}`, FAKE_HOST) - // convert /foo/bar.md to /foo/bar.html - if (routePath.endsWith('.md')) { - routePath = routePath.substring(0, routePath.length - 3) + '.html' - } - // convert /foo/bar to /foo/bar.html - else if (!routePath.endsWith('.html')) { - routePath = routePath + '.html' + return inferRoutePath(pathname) + search + hash } - // convert /foo/index.html to /foo/ - if (routePath.endsWith('/index.html')) { - routePath = routePath.substring(0, routePath.length - 10) - } + const [pathname, ...queryAndHash] = path.split(/(\?|#)/) - // add query and hash back - return routePath + queryAndHash.join('') + return inferRoutePath(pathname) + queryAndHash.join('') } diff --git a/packages/shared/tests/inferRoutePath.spec.ts b/packages/shared/tests/inferRoutePath.spec.ts new file mode 100644 index 0000000000..36bd910b96 --- /dev/null +++ b/packages/shared/tests/inferRoutePath.spec.ts @@ -0,0 +1,62 @@ +import { describe, expect, it } from 'vitest' +import { inferRoutePath } from '../src/index.js' + +const testCases = [ + // absolute index + ['/', '/'], + ['/README.md', '/'], + ['/readme.md', '/'], + ['/index.md', '/'], + ['/index.html', '/'], + ['/index', '/'], + ['/foo/', '/foo/'], + ['/foo/README.md', '/foo/'], + ['/foo/readme.md', '/foo/'], + ['/foo/index.md', '/foo/'], + ['/foo/index.html', '/foo/'], + ['/foo/index', '/foo/'], + ['README.md', 'index.html'], + ['readme.md', 'index.html'], + ['index.md', 'index.html'], + ['index.html', 'index.html'], + ['index', 'index.html'], + + // absolute non-index + ['/foo', '/foo.html'], + ['/foo.md', '/foo.html'], + ['/foo.html', '/foo.html'], + ['/foo/bar', '/foo/bar.html'], + ['/foo/bar.md', '/foo/bar.html'], + ['/foo/bar.html', '/foo/bar.html'], + + // relative index without current + ['foo/', 'foo/'], + ['foo/README.md', 'foo/'], + ['foo/readme.md', 'foo/'], + ['foo/index.md', 'foo/'], + ['foo/index.html', 'foo/'], + ['foo/index', 'foo/'], + + // relative non index without current + ['foo', 'foo.html'], + ['foo.md', 'foo.html'], + ['foo.html', 'foo.html'], + ['foo/bar', 'foo/bar.html'], + ['foo/bar.md', 'foo/bar.html'], + ['foo/bar.html', 'foo/bar.html'], + + // unexpected corner cases + ['', ''], + ['.md', '.html'], + ['foo/.md', 'foo/.html'], + ['/.md', '/.html'], + ['/foo/.md', '/foo/.html'], +] + +describe('should normalize clean paths correctly', () => { + testCases.forEach(([path, expected]) => + it(`"${path}" -> "${expected}"`, () => { + expect(inferRoutePath(path)).toBe(expected) + }), + ) +}) diff --git a/packages/shared/tests/normalizeRoutePath.spec.ts b/packages/shared/tests/normalizeRoutePath.spec.ts index e19287705d..6543ab24e2 100644 --- a/packages/shared/tests/normalizeRoutePath.spec.ts +++ b/packages/shared/tests/normalizeRoutePath.spec.ts @@ -2,109 +2,244 @@ import { describe, expect, it } from 'vitest' import { normalizeRoutePath } from '../src/index.js' const testCases = [ - // index - ['/', '/'], - ['/README.md', '/'], - ['/readme.md', '/'], - ['/index.md', '/'], - ['/index.html', '/'], - ['/index', '/'], - ['/foo/', '/foo/'], - ['/foo/README.md', '/foo/'], - ['/foo/readme.md', '/foo/'], - ['/foo/index.md', '/foo/'], - ['/foo/index.html', '/foo/'], - ['/foo/index', '/foo/'], - ['', ''], - ['README.md', 'index.html'], - ['readme.md', 'index.html'], - ['index.md', 'index.html'], - ['index.html', 'index.html'], - ['index', 'index.html'], - ['foo/', 'foo/'], - ['foo/README.md', 'foo/'], - ['foo/readme.md', 'foo/'], - ['foo/index.md', 'foo/'], - ['foo/index.html', 'foo/'], - ['foo/index', 'foo/'], + // absolute index + [['/'], '/'], + [['/README.md'], '/'], + [['/readme.md'], '/'], + [['/index.md'], '/'], + [['/index.html'], '/'], + [['/index'], '/'], + [['/foo/'], '/foo/'], + [['/foo/README.md'], '/foo/'], + [['/foo/readme.md'], '/foo/'], + [['/foo/index.md'], '/foo/'], + [['/foo/index.html'], '/foo/'], + [['/foo/index'], '/foo/'], + [['README.md'], 'index.html'], + [['readme.md'], 'index.html'], + [['index.md'], 'index.html'], + [['index.html'], 'index.html'], + [['index'], 'index.html'], - // non-index - ['/foo', '/foo.html'], - ['/foo.md', '/foo.html'], - ['/foo.html', '/foo.html'], - ['/foo/bar', '/foo/bar.html'], - ['/foo/bar.md', '/foo/bar.html'], - ['/foo/bar.html', '/foo/bar.html'], - ['foo', 'foo.html'], - ['foo.md', 'foo.html'], - ['foo.html', 'foo.html'], - ['foo/bar', 'foo/bar.html'], - ['foo/bar.md', 'foo/bar.html'], - ['foo/bar.html', 'foo/bar.html'], + // absolute non-index + [['/foo'], '/foo.html'], + [['/foo.md'], '/foo.html'], + [['/foo.html'], '/foo.html'], + [['/foo/bar'], '/foo/bar.html'], + [['/foo/bar.md'], '/foo/bar.html'], + [['/foo/bar.html'], '/foo/bar.html'], - // hash and query - ['/foo#bar', '/foo.html#bar'], - ['/foo.md#bar', '/foo.html#bar'], - ['/foo.html#bar', '/foo.html#bar'], - ['/foo?bar=baz', '/foo.html?bar=baz'], - ['/foo.md?bar=baz', '/foo.html?bar=baz'], - ['/foo.html?bar=baz', '/foo.html?bar=baz'], - ['/foo?bar=baz#qux', '/foo.html?bar=baz#qux'], - ['/foo.md?bar=baz#qux', '/foo.html?bar=baz#qux'], - ['/foo.html?bar=baz#qux', '/foo.html?bar=baz#qux'], - ['foo#bar', 'foo.html#bar'], - ['foo.md#bar', 'foo.html#bar'], - ['foo.html#bar', 'foo.html#bar'], - ['foo?bar=baz', 'foo.html?bar=baz'], - ['foo.md?bar=baz', 'foo.html?bar=baz'], - ['foo.html?bar=baz', 'foo.html?bar=baz'], - ['foo?bar=baz#qux', 'foo.html?bar=baz#qux'], - ['foo.md?bar=baz#qux', 'foo.html?bar=baz#qux'], - ['foo.html?bar=baz#qux', 'foo.html?bar=baz#qux'], - ['#bar', '#bar'], - ['?bar=baz', '?bar=baz'], - ['?bar=baz#qux', '?bar=baz#qux'], + // relative index without current + [['foo/'], 'foo/'], + [['foo/README.md'], 'foo/'], + [['foo/readme.md'], 'foo/'], + [['foo/index.md'], 'foo/'], + [['foo/index.html'], 'foo/'], + [['foo/index'], 'foo/'], + + // relative non index without current + [['foo'], 'foo.html'], + [['foo.md'], 'foo.html'], + [['foo.html'], 'foo.html'], + [['foo/bar'], 'foo/bar.html'], + [['foo/bar.md'], 'foo/bar.html'], + [['foo/bar.html'], 'foo/bar.html'], + + // relative non index with current + [['foo', '/'], '/foo.html'], + [['foo', '/a.html'], '/foo.html'], + [['foo', '/index.html'], '/foo.html'], + [['foo', '/a/'], '/a/foo.html'], + [['foo', '/a/index.html'], '/a/foo.html'], + [['foo', '/a/b.html'], '/a/foo.html'], + [['foo.md', '/'], '/foo.html'], + [['foo.md', '/a.html'], '/foo.html'], + [['foo.md', '/index.html'], '/foo.html'], + [['foo.md', '/a/'], '/a/foo.html'], + [['foo.md', '/a/index.html'], '/a/foo.html'], + [['foo.md', '/a/b.html'], '/a/foo.html'], + [['foo.html', '/'], '/foo.html'], + [['foo.html', '/a.html'], '/foo.html'], + [['foo.html', '/index.html'], '/foo.html'], + [['foo.html', '/a/'], '/a/foo.html'], + [['foo.html', '/a/index.html'], '/a/foo.html'], + [['foo.html', '/a/b.html'], '/a/foo.html'], + [['foo/bar', '/'], '/foo/bar.html'], + [['foo/bar', '/a.html'], '/foo/bar.html'], + [['foo/bar', '/index.html'], '/foo/bar.html'], + [['foo/bar', '/a/'], '/a/foo/bar.html'], + [['foo/bar', '/a/index.html'], '/a/foo/bar.html'], + [['foo/bar', '/a/b.html'], '/a/foo/bar.html'], + [['foo/bar.md', '/'], '/foo/bar.html'], + [['foo/bar.md', '/a.html'], '/foo/bar.html'], + [['foo/bar.md', '/index.html'], '/foo/bar.html'], + [['foo/bar.md', '/a/'], '/a/foo/bar.html'], + [['foo/bar.md', '/a/index.html'], '/a/foo/bar.html'], + [['foo/bar.md', '/a/b.html'], '/a/foo/bar.html'], + [['foo/bar.html', '/'], '/foo/bar.html'], + [['foo/bar.html', '/a.html'], '/foo/bar.html'], + [['foo/bar.html', '/index.html'], '/foo/bar.html'], + [['foo/bar.html', '/a/'], '/a/foo/bar.html'], + [['foo/bar.html', '/a/index.html'], '/a/foo/bar.html'], + [['foo/bar.html', '/a/b.html'], '/a/foo/bar.html'], + [['./foo', '/'], '/foo.html'], + [['./foo', '/a.html'], '/foo.html'], + [['./foo', '/index.html'], '/foo.html'], + [['./foo', '/a/'], '/a/foo.html'], + [['./foo', '/a/index.html'], '/a/foo.html'], + [['./foo', '/a/b.html'], '/a/foo.html'], + [['./foo.md', '/'], '/foo.html'], + [['./foo.md', '/a.html'], '/foo.html'], + [['./foo.md', '/index.html'], '/foo.html'], + [['./foo.md', '/a/'], '/a/foo.html'], + [['./foo.md', '/a/index.html'], '/a/foo.html'], + [['./foo.md', '/a/b.html'], '/a/foo.html'], + [['./foo.html', '/'], '/foo.html'], + [['./foo.html', '/a.html'], '/foo.html'], + [['./foo.html', '/index.html'], '/foo.html'], + [['./foo.html', '/a/'], '/a/foo.html'], + [['./foo.html', '/a/index.html'], '/a/foo.html'], + [['./foo.html', '/a/b.html'], '/a/foo.html'], + [['./foo/bar', '/'], '/foo/bar.html'], + [['./foo/bar', '/a.html'], '/foo/bar.html'], + [['./foo/bar', '/index.html'], '/foo/bar.html'], + [['./foo/bar', '/a/'], '/a/foo/bar.html'], + [['./foo/bar', '/a/index.html'], '/a/foo/bar.html'], + [['./foo/bar', '/a/b.html'], '/a/foo/bar.html'], + [['./foo/bar.md', '/'], '/foo/bar.html'], + [['./foo/bar.md', '/a.html'], '/foo/bar.html'], + [['./foo/bar.md', '/index.html'], '/foo/bar.html'], + [['./foo/bar.md', '/a/'], '/a/foo/bar.html'], + [['./foo/bar.md', '/a/index.html'], '/a/foo/bar.html'], + [['./foo/bar.md', '/a/b.html'], '/a/foo/bar.html'], + [['./foo/bar.html', '/'], '/foo/bar.html'], + [['./foo/bar.html', '/a.html'], '/foo/bar.html'], + [['./foo/bar.html', '/index.html'], '/foo/bar.html'], + [['./foo/bar.html', '/a/'], '/a/foo/bar.html'], + [['./foo/bar.html', '/a/index.html'], '/a/foo/bar.html'], + [['./foo/bar.html', '/a/b.html'], '/a/foo/bar.html'], + [['../foo', '/a/'], '/foo.html'], + [['../foo', '/a/index.html'], '/foo.html'], + [['../foo', '/a/b.html'], '/foo.html'], + [['../foo.md', '/a/'], '/foo.html'], + [['../foo.md', '/a/index.html'], '/foo.html'], + [['../foo.md', '/a/b.html'], '/foo.html'], + [['../foo.html', '/a/'], '/foo.html'], + [['../foo.html', '/a/index.html'], '/foo.html'], + [['../foo.html', '/a/b.html'], '/foo.html'], + [['../foo/bar', '/a/'], '/foo/bar.html'], + [['../foo/bar', '/a/index.html'], '/foo/bar.html'], + [['../foo/bar', '/a/b.html'], '/foo/bar.html'], + [['../foo/bar.md', '/a/'], '/foo/bar.html'], + [['../foo/bar.md', '/a/index.html'], '/foo/bar.html'], + [['../foo/bar.md', '/a/b.html'], '/foo/bar.html'], + [['../foo/bar.html', '/a/'], '/foo/bar.html'], + [['../foo/bar.html', '/a/index.html'], '/foo/bar.html'], + [['../foo/bar.html', '/a/b.html'], '/foo/bar.html'], + + // absolute non index with current + [['/foo', '/'], '/foo.html'], + [['/foo', '/a.html'], '/foo.html'], + [['/foo', '/index.html'], '/foo.html'], + [['/foo', '/a/'], '/foo.html'], + [['/foo', '/a/index.html'], '/foo.html'], + [['/foo', '/a/b.html'], '/foo.html'], + [['/foo.md', '/'], '/foo.html'], + [['/foo.md', '/a.html'], '/foo.html'], + [['/foo.md', '/index.html'], '/foo.html'], + [['/foo.md', '/a/'], '/foo.html'], + [['/foo.md', '/a/index.html'], '/foo.html'], + [['/foo.md', '/a/b.html'], '/foo.html'], + [['/foo.html', '/'], '/foo.html'], + [['/foo.html', '/a.html'], '/foo.html'], + [['/foo.html', '/index.html'], '/foo.html'], + [['/foo.html', '/a/'], '/foo.html'], + [['/foo.html', '/a/index.html'], '/foo.html'], + [['/foo.html', '/a/b.html'], '/foo.html'], + [['/foo/bar', '/'], '/foo/bar.html'], + [['/foo/bar', '/a.html'], '/foo/bar.html'], + [['/foo/bar', '/index.html'], '/foo/bar.html'], + [['/foo/bar', '/a/'], '/foo/bar.html'], + [['/foo/bar', '/a/index.html'], '/foo/bar.html'], + [['/foo/bar', '/a/b.html'], '/foo/bar.html'], + [['/foo/bar.md', '/'], '/foo/bar.html'], + [['/foo/bar.md', '/a.html'], '/foo/bar.html'], + [['/foo/bar.md', '/index.html'], '/foo/bar.html'], + [['/foo/bar.md', '/a/'], '/foo/bar.html'], + [['/foo/bar.md', '/a/index.html'], '/foo/bar.html'], + [['/foo/bar.md', '/a/b.html'], '/foo/bar.html'], + [['/foo/bar.html', '/'], '/foo/bar.html'], + [['/foo/bar.html', '/a.html'], '/foo/bar.html'], + [['/foo/bar.html', '/index.html'], '/foo/bar.html'], + [['/foo/bar.html', '/a/'], '/foo/bar.html'], + [['/foo/bar.html', '/a/index.html'], '/foo/bar.html'], + [['/foo/bar.html', '/a/b.html'], '/foo/bar.html'], + + // only hash and query + [[''], ''], // unexpected corner cases - ['.md', '.html'], - ['foo/.md', 'foo/.html'], - ['/.md', '/.html'], - ['/foo/.md', '/foo/.html'], + [['.md'], '.html'], + [['foo/.md'], 'foo/.html'], + [['/.md'], '/.html'], + [['/foo/.md'], '/foo/.html'], + [['.md', '/a/'], '/a/.html'], + [['foo/.md', '/a/'], '/a/foo/.html'], + [['/.md', '/a/'], '/.html'], + [['/foo/.md', '/a/'], '/foo/.html'], + [['.md', '/a/index.html'], '/a/.html'], + [['foo/.md', '/a/index.html'], '/a/foo/.html'], + [['/.md', '/a/index.html'], '/.html'], + [['/foo/.md', '/a/index.html'], '/foo/.html'], + [['.md', '/a/b.html'], '/a/.html'], + [['foo/.md', '/a/b.html'], '/a/foo/.html'], + [['/.md', '/a/b.html'], '/.html'], + [['/foo/.md', '/a/b.html'], '/foo/.html'], ] -describe('should normalize clean paths correctly', () => - testCases.forEach(([path, expected]) => - it(`"${path}" -> "${expected}"`, () => { - expect(normalizeRoutePath(path)).toBe(expected) +describe('should normalize clean paths correctly', () => { + testCases.forEach(([[path, current], expected]) => + it(`${current ? `"${current}"-` : ''}"${path}" -> "${expected}"`, () => { + expect(normalizeRoutePath(path, current)).toBe(expected) }), - )) + ) +}) -describe('should normalize paths with query correctly', () => +describe('should normalize paths with query correctly', () => { testCases - .map(([path, expected]) => [`${path}?foo=bar`, `${expected}?foo=bar`]) - .forEach(([path, expected]) => - it(`"${path}" -> "${expected}"`, () => { - expect(normalizeRoutePath(path)).toBe(expected) + .map(([[path, current], expected]) => [ + [`${path}?foo=bar`, current], + `${expected}?foo=bar`, + ]) + .forEach(([[path, current], expected]) => + it(`${current ? `"${current}"-` : ''}"${path}" -> "${expected}"`, () => { + expect(normalizeRoutePath(path, current)).toBe(expected) }), - )) + ) +}) -describe('should normalize paths with hash correctly', () => +describe('should normalize paths with hash correctly', () => { testCases - .map(([path, expected]) => [`${path}#foobar`, `${expected}#foobar`]) - .forEach(([path, expected]) => - it(`"${path}" -> "${expected}"`, () => { - expect(normalizeRoutePath(path)).toBe(expected) + .map(([[path, current], expected]) => [ + [`${path}#foobar`, current], + `${expected}#foobar`, + ]) + .map(([[path, current], expected]) => + it(`${current ? `"${current}"-` : ''}"${path}" -> "${expected}"`, () => { + expect(normalizeRoutePath(path, current)).toBe(expected) }), - )) + ) +}) -describe('should normalize paths with query and hash correctly', () => +describe('should normalize paths with query and hash correctly', () => { testCases - .map(([path, expected]) => [ - `${path}?foo=1&bar=2#foobar`, + .map(([[path, current], expected]) => [ + [`${path}?foo=1&bar=2#foobar`, current], `${expected}?foo=1&bar=2#foobar`, ]) - .forEach(([path, expected]) => - it(`"${path}" -> "${expected}"`, () => { - expect(normalizeRoutePath(path)).toBe(expected) + .map(([[path, current], expected]) => + it(`${current ? `"${current}"-` : ''}"${path}" -> "${expected}"`, () => { + expect(normalizeRoutePath(path, current)).toBe(expected) }), - )) + ) +})