Core & UI: Hotfix URL normalization logic

This commit is contained in:
Fuma Nama
2025-08-29 12:28:27 +08:00
parent 6aaa2aacd1
commit cedc494bb8
16 changed files with 147 additions and 93 deletions
+6
View File
@@ -0,0 +1,6 @@
---
'fumadocs-core': patch
'fumadocs-ui': patch
---
Hotfix URL normalization logic
+7 -1
View File
@@ -9,5 +9,11 @@
"module": "ESNext",
"target": "ES2020"
},
"exclude": ["node_modules", "test/repo", "test/repo-2", "eslint.config.mjs"]
"exclude": [
"dist",
"node_modules",
"test/repo",
"test/repo-2",
"eslint.config.mjs"
]
}
+28 -27
View File
@@ -1,5 +1,6 @@
import { type ReactNode, useMemo } from 'react';
import type * as PageTree from '@/source/page-tree/definitions';
import { normalizeUrl } from '@/utils/normalize-url';
export interface BreadcrumbItem {
name: ReactNode;
@@ -117,42 +118,42 @@ export function searchPath(
nodes: PageTree.Node[],
url: string,
): PageTree.Node[] | null {
if (url.endsWith('/')) url = url.slice(0, -1);
const items: PageTree.Node[] = [];
url = normalizeUrl(url);
let separator: PageTree.Separator | undefined;
function run(nodes: PageTree.Node[]): boolean {
let separator: PageTree.Separator | undefined;
for (const node of nodes) {
if (node.type === 'separator') separator = node;
for (const node of nodes) {
if (node.type === 'separator') separator = node;
if (node.type === 'folder') {
if (node.index?.url === url) {
const items: PageTree.Node[] = [];
if (node.type === 'folder') {
if (node.index?.url === url) {
if (separator) items.push(separator);
items.push(node, node.index);
return true;
}
if (run(node.children)) {
items.unshift(node);
if (separator) items.unshift(separator);
return true;
}
}
if (node.type === 'page' && node.url === url) {
if (separator) items.push(separator);
items.push(node, node.index);
items.push(node);
return items;
}
const items = searchPath(node.children, url);
if (items) {
items.unshift(node);
if (separator) items.unshift(separator);
return items;
return true;
}
}
if (node.type === 'page' && node.url === url) {
const items: PageTree.Node[] = [];
if (separator) items.push(separator);
items.push(node);
return items;
}
return false;
}
return null;
run(nodes);
return items;
}
@@ -4,7 +4,6 @@ import type { Transformer } from 'unified';
import { visit } from 'unist-util-visit';
import { imageSize } from 'image-size';
import type { MdxjsEsm } from 'mdast-util-mdxjs-esm';
import { joinPath, slash } from '@/utils/path';
import type { ISizeCalculationResult } from 'image-size/types/interface';
import { imageSizeFromFile } from 'image-size/fromFile';
import type { MdxJsxFlowElement } from 'mdast-util-mdx-jsx';
@@ -252,7 +251,7 @@ export function remarkImage({
}
function getImportPath(file: string, dir: string): string {
const relative = slash(path.relative(dir, file));
const relative = path.relative(dir, file).replaceAll(path.sep, '/');
return relative.startsWith('../') ? relative : `./${relative}`;
}
@@ -280,7 +279,11 @@ function parseSrc(
if (src.startsWith('/')) {
if (EXTERNAL_URL_REGEX.test(publicDir)) {
const url = new URL(publicDir);
url.pathname = joinPath(url.pathname, src);
const segs = [...url.pathname.split('/'), ...src.split('/')].filter(
(v) => v.length > 0,
);
url.pathname = `/${segs.join('/')}`;
return { type: 'url', url };
}
+1 -2
View File
@@ -1,5 +1,4 @@
import { dirname } from './path';
import { splitPath } from '@/utils/path';
import { dirname, splitPath } from './path';
/**
* In memory file system.
+1 -2
View File
@@ -1,8 +1,7 @@
import { type MetaData, type PageData } from '@/source/types';
import { FileSystem } from './file-system';
import { joinPath, slash, splitPath } from '@/utils/path';
import { type VirtualFile } from '@/source/loader';
import { basename, dirname } from '@/source/path';
import { basename, dirname, joinPath, slash, splitPath } from '@/source/path';
import type { I18nConfig } from '@/i18n';
export interface LoadOptions {
+6 -2
View File
@@ -14,9 +14,10 @@ import {
dirname,
extname,
type FileInfo,
joinPath,
parseFilePath,
} from './path';
import { joinPath } from '@/utils/path';
import { normalizeUrl } from '@/utils/normalize-url';
export interface LoaderConfig {
source: SourceConfig;
@@ -278,9 +279,12 @@ function createOutput(options: LoaderOptions): LoaderOutput<LoaderConfig> {
baseUrl = '/',
i18n,
slugs: slugsFn,
url: getUrl = createGetUrl(baseUrl ?? '/', i18n),
url: urlFn,
transformers = [],
} = options;
const getUrl: UrlFn = urlFn
? (...args) => normalizeUrl(urlFn(...args))
: createGetUrl(baseUrl, i18n);
const defaultLanguage = i18n?.defaultLanguage ?? '';
const files = loadSource(source);
@@ -1,9 +1,8 @@
import type { ReactNode } from 'react';
import type * as PageTree from './definitions';
import { joinPath } from '@/utils/path';
import type { MetaData, PageData, UrlFn } from '../types';
import type { ContentStorage } from '@/source/load-files';
import { basename, extname } from '@/source/path';
import { basename, extname, joinPath } from '@/source/path';
import {
legacyTransformer,
type LegacyTransformerOptions,
+47
View File
@@ -89,3 +89,50 @@ export function parseFolderPath(path: string): FolderInfo {
path,
};
}
/**
* Split path into segments, trailing/leading slashes are removed
*/
export function splitPath(path: string): string[] {
return path.split('/').filter((p) => p.length > 0);
}
/**
* Resolve paths, slashes within the path will be ignored
* @param paths - Paths to join
* @example
* ```
* ['a','b'] // 'a/b'
* ['/a'] // 'a'
* ['a', '/b'] // 'a/b'
* ['a', '../b/c'] // 'b/c'
* ```
*/
export function joinPath(...paths: string[]): string {
const out = [];
const parsed = paths.flatMap(splitPath);
for (const seg of parsed) {
switch (seg) {
case '..':
out.pop();
break;
case '.':
break;
default:
out.push(seg);
}
}
return out.join('/');
}
export function slash(path: string): string {
const isExtendedLengthPath = path.startsWith('\\\\?\\');
if (isExtendedLengthPath) {
return path;
}
return path.replaceAll('\\', '/');
}
+10
View File
@@ -0,0 +1,10 @@
/**
* normalize URL into the Fumadocs standard form (`/slug-1/slug-2`)
*/
export function normalizeUrl(url: string) {
if (url.startsWith('http://') || url.startsWith('https://')) return url;
if (!url.startsWith('/')) url = '/' + url;
if (url.length > 1 && url.endsWith('/')) url = url.slice(0, -1);
return url;
}
-46
View File
@@ -1,46 +0,0 @@
/**
* Split path into segments, trailing/leading slashes are removed
*/
export function splitPath(path: string): string[] {
return path.split('/').filter((p) => p.length > 0);
}
/**
* Resolve paths, slashes within the path will be ignored
* @param paths - Paths to join
* @example
* ```
* ['a','b'] // 'a/b'
* ['/a'] // 'a'
* ['a', '/b'] // 'a/b'
* ['a', '../b/c'] // 'b/c'
* ```
*/
export function joinPath(...paths: string[]): string {
const out = [];
const parsed = paths.flatMap(splitPath);
for (const seg of parsed) {
switch (seg) {
case '..':
out.pop();
break;
case '.':
break;
default:
out.push(seg);
}
}
return out.join('/');
}
export function slash(path: string): string {
const isExtendedLengthPath = path.startsWith('\\\\?\\');
if (isExtendedLengthPath) {
return path;
}
return path.replaceAll('\\', '/');
}
+20 -2
View File
@@ -1,10 +1,14 @@
import { parseFilePath, parseFolderPath } from '@/source/path';
import {
joinPath,
parseFilePath,
parseFolderPath,
splitPath,
} from '@/source/path';
import { describe, expect, test } from 'vitest';
import type { Root } from '@/source/page-tree/definitions';
import { findNeighbour } from '@/utils/page-tree';
import { PageTree } from '../dist/server';
import { getBreadcrumbItems } from '@/breadcrumb';
import { joinPath, splitPath } from '@/utils/path';
test('Find Neighbours', () => {
const tree: Root = {
@@ -117,6 +121,11 @@ test('Breadcrumbs', () => {
const tree: PageTree.Root = {
name: 'Hello World',
children: [
{
type: 'page',
name: 'Introduction',
url: '/',
},
{
type: 'page',
name: 'Hello World',
@@ -148,4 +157,13 @@ test('Breadcrumbs', () => {
expect(getBreadcrumbItems('/docs/folder', tree)).toStrictEqual([
{ name: 'World', url: '/docs/folder' },
]);
expect(getBreadcrumbItems('/', tree)).toMatchInlineSnapshot(`
[
{
"name": "Introduction",
"url": "/",
},
]
`);
});
+4 -1
View File
@@ -1,4 +1,4 @@
This is your new *vault*.
This is your new _vault_.
Make a note of something, [[create a link]], or try [the Importer](https://help.obsidian.md/Plugins/Importer)!
@@ -6,8 +6,11 @@ Make a note of something, [[create a link]], or try [the Importer](https://help.
When you're ready, delete this note and make the vault your own.
![[Xmas.png]]
## Introduction!!
## Introduction!!
## Introduction!!
## Introduction!!
+2 -2
View File
@@ -1,5 +1,5 @@
hi [[Welcome]] [[Welcome#Introduction!!]]
```ts
console.log("Hello World")
```
console.log('Hello World');
```
+1 -1
View File
@@ -1,5 +1,5 @@
hi [Welcome](./Welcome.mdx)[Welcome](./Welcome.mdx#introduction)
```ts
console.log("Hello World")
console.log('Hello World');
```
+7 -2
View File
@@ -1,10 +1,15 @@
function normalize(url: string) {
if (url.length > 1 && url.endsWith('/')) return url.slice(0, -1);
return url;
}
export function isActive(
url: string,
pathname: string,
nested = true,
): boolean {
if (url.endsWith('/')) url = url.slice(0, -1);
if (pathname.endsWith('/')) pathname = pathname.slice(0, -1);
url = normalize(url);
pathname = normalize(pathname);
return url === pathname || (nested && pathname.startsWith(`${url}/`));
}