Files
ONE-OS/axhub-make/vite-plugins/virtualHtml/handlers/pathNormalizer.ts
王冕 a27e3b8e43 feat: sync full workspace including web modules, docs, and configurations to Gitea
Optimized the root .gitignore to exclude virtual environments, node modules,
and temp folders to ensure clean and lightweight version tracking.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-09 18:12:25 +08:00

434 lines
15 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import type { IncomingMessage, ServerResponse } from 'http';
import { logVirtualHtmlDebug } from '../logger';
import fs from 'fs';
import path from 'path';
import { readEntriesManifest } from '../../utils/entriesManifest';
/**
* 路径标准化器
*
* 新路径格式(推荐):
* - /prototypes/{name} → 原型预览
* - /prototypes/{name}/spec → 原型文档
* - /components/{name} → 组件预览
* - /components/{name}/spec → 组件文档
* - /themes/{name} → 主题预览
* - /themes/{name}/spec → 主题文档
* - /docs/{name} → 系统文档
*
* 旧路径格式(兼容):
* - /{name}.html → 重定向到新格式
* - /{name}/spec.html → 重定向到新格式
* - /prototypes/{name}/index.html → 重定向到新格式
* - /components/{name}/index.html → 重定向到新格式
* - /assets/docs/{name}/spec.html → 重定向到新格式
*/
export interface NormalizedPath {
type: 'prototypes' | 'components' | 'themes' | 'docs';
name: string;
action: 'preview' | 'spec';
isLegacy: boolean;
originalUrl: string;
normalizedUrl: string;
versionId?: string;
subPath?: string;
}
function safeDecodeURIComponent(value: string): string {
try {
return decodeURIComponent(value);
} catch {
return value;
}
}
function decodePathSegments(parts: string[]): string[] {
return parts.map((part) => safeDecodeURIComponent(part));
}
function looksLikeFileRequest(subPath: string): boolean {
if (!subPath) return false;
const lastSegment = subPath.split('/').filter(Boolean).pop() || '';
return /\.[a-z0-9]+$/i.test(lastSegment);
}
export function encodeRoutePath(pathname: string): string {
const hasLeadingSlash = pathname.startsWith('/');
const hasTrailingSlash = pathname.endsWith('/') && pathname !== '/';
const encoded = pathname
.split('/')
.filter(Boolean)
.map((segment) => encodeURIComponent(safeDecodeURIComponent(segment)))
.join('/');
const withLeadingSlash = hasLeadingSlash ? `/${encoded}` : encoded;
if (hasTrailingSlash && withLeadingSlash) {
return `${withLeadingSlash}/`;
}
return withLeadingSlash || (hasLeadingSlash ? '/' : '');
}
function resolveEntryTypeByName(name: string): 'prototypes' | 'components' | 'themes' | null {
const projectRoot = process.cwd();
const normalizedName = String(name || '').trim();
if (!normalizedName) return null;
const scanOrder: Array<'prototypes' | 'components' | 'themes'> = ['prototypes', 'components', 'themes'];
for (const type of scanOrder) {
const entryPath = path.resolve(projectRoot, 'src', type, normalizedName, 'index.tsx');
if (fs.existsSync(entryPath)) {
return type;
}
}
try {
const manifest = readEntriesManifest(projectRoot);
for (const type of scanOrder) {
if (manifest.items?.[`${type}/${normalizedName}`]) {
return type;
}
}
} catch {
// ignore manifest read errors and keep null fallback
}
return null;
}
function resolveTypedEntryName(
type: 'prototypes' | 'components' | 'themes',
nameParts: string[],
): { name: string; restParts: string[] } | null {
const projectRoot = process.cwd();
for (let partCount = nameParts.length; partCount >= 1; partCount -= 1) {
const candidateName = nameParts.slice(0, partCount).join('/');
const restParts = nameParts.slice(partCount);
const entryPath = path.resolve(projectRoot, 'src', type, candidateName, 'index.tsx');
if (fs.existsSync(entryPath)) {
return { name: candidateName, restParts };
}
}
try {
const manifest = readEntriesManifest(projectRoot);
for (let partCount = nameParts.length; partCount >= 1; partCount -= 1) {
const candidateName = nameParts.slice(0, partCount).join('/');
if (manifest.items?.[`${type}/${candidateName}`]) {
return {
name: candidateName,
restParts: nameParts.slice(partCount),
};
}
}
} catch {
// ignore manifest read errors and keep null fallback
}
return null;
}
/**
* 解析并标准化路径
*/
export function normalizePath(url: string): NormalizedPath | null {
const [urlWithoutQuery, queryString] = url.split('?');
const params = new URLSearchParams(queryString || '');
const versionId = params.get('ver') || undefined;
// Vite 内部的 html-proxy 请求需要保留原样,不能参与旧路径重定向。
// 否则浏览器在加载 /index.html?html-proxy&index=*.js 时会被 301 到页面地址,
// 最终表现为 script 资源加载失败。
if (params.has('html-proxy')) {
return null;
}
// 移除末尾的 .html
const cleanUrl = urlWithoutQuery.replace(/\.html$/, '');
// 解析路径部分
const pathParts = cleanUrl.split('/').filter(Boolean);
if (pathParts.length === 0) return null;
// 文档静态资源路径不参与页面路由标准化,交给资源处理器兜底。
if (pathParts[0] === 'docs' && pathParts.includes('assets')) {
return null;
}
// 情况 1: /prototypes/{name} 或 /prototypes/{name}/spec 或 /prototypes/{name}/index
if (pathParts[0] === 'prototypes' && pathParts.length >= 2) {
const decodedNameParts = decodePathSegments(pathParts.slice(1));
const resolved = resolveTypedEntryName('prototypes', decodedNameParts);
if (!resolved) return null;
const lastPart = resolved.restParts[resolved.restParts.length - 1];
const isSpecRoute = resolved.restParts.length === 1 && lastPart === 'spec';
const isLegacyIndexRoute = resolved.restParts.length === 1 && lastPart === 'index';
const subPath = !isSpecRoute && !isLegacyIndexRoute ? resolved.restParts.join('/') : '';
// 真实文件请求(如 index.tsx、style.css应该交给 Vite 模块系统处理,
// 不能被误判成页面预览路由,否则会返回 HTML 导致模块加载失败。
if (looksLikeFileRequest(subPath)) {
return null;
}
if (resolved.restParts.length === 0 || !isSpecRoute) {
// /prototypes/{name} 或 /prototypes/{name}/index.html
return {
type: 'prototypes',
name: resolved.name,
action: 'preview',
isLegacy: isLegacyIndexRoute,
originalUrl: url,
normalizedUrl: `${encodeRoutePath(`/prototypes/${resolved.name}${subPath ? `/${subPath}` : ''}`)}${versionId ? `?ver=${versionId}` : ''}`,
versionId,
subPath: subPath || undefined,
};
} else if (isSpecRoute) {
// /prototypes/{name}/spec 或 /prototypes/{name}/spec.html
return {
type: 'prototypes',
name: resolved.name,
action: 'spec',
isLegacy: urlWithoutQuery.includes('.html'),
originalUrl: url,
normalizedUrl: `${encodeRoutePath(`/prototypes/${resolved.name}/spec`)}${versionId ? `?ver=${versionId}` : ''}`,
versionId
};
}
}
// 情况 2: /components/{name} 或 /components/{name}/spec 或 /components/{name}/index
if (pathParts[0] === 'components' && pathParts.length >= 2) {
const decodedNameParts = decodePathSegments(pathParts.slice(1));
const resolved = resolveTypedEntryName('components', decodedNameParts);
if (!resolved) return null;
const lastPart = resolved.restParts[resolved.restParts.length - 1];
const isSpecRoute = resolved.restParts.length === 1 && lastPart === 'spec';
const isLegacyIndexRoute = resolved.restParts.length === 1 && lastPart === 'index';
const subPath = !isSpecRoute && !isLegacyIndexRoute ? resolved.restParts.join('/') : '';
// 真实文件请求(如 index.tsx、style.css应该交给 Vite 模块系统处理,
// 不能被误判成页面预览路由,否则会返回 HTML 导致模块加载失败。
if (looksLikeFileRequest(subPath)) {
return null;
}
if (resolved.restParts.length === 0 || !isSpecRoute) {
// /components/{name} 或 /components/{name}/index.html
return {
type: 'components',
name: resolved.name,
action: 'preview',
isLegacy: isLegacyIndexRoute,
originalUrl: url,
normalizedUrl: `${encodeRoutePath(`/components/${resolved.name}${subPath ? `/${subPath}` : ''}`)}${versionId ? `?ver=${versionId}` : ''}`,
versionId,
subPath: subPath || undefined,
};
} else if (isSpecRoute) {
// /components/{name}/spec 或 /components/{name}/spec.html
return {
type: 'components',
name: resolved.name,
action: 'spec',
isLegacy: urlWithoutQuery.includes('.html'),
originalUrl: url,
normalizedUrl: `${encodeRoutePath(`/components/${resolved.name}/spec`)}${versionId ? `?ver=${versionId}` : ''}`,
versionId
};
}
}
// 情况 3: /themes/{name} 或 /themes/{name}/spec 或 /themes/{name}/index
if (pathParts[0] === 'themes' && pathParts.length >= 2) {
const decodedNameParts = decodePathSegments(pathParts.slice(1));
const resolved = resolveTypedEntryName('themes', decodedNameParts);
if (!resolved) return null;
const lastPart = resolved.restParts[resolved.restParts.length - 1];
const isSpecRoute = resolved.restParts.length === 1 && lastPart === 'spec';
const isLegacyIndexRoute = resolved.restParts.length === 1 && lastPart === 'index';
const subPath = !isSpecRoute && !isLegacyIndexRoute ? resolved.restParts.join('/') : '';
// 真实文件请求(如 index.tsx、style.css应该交给 Vite 模块系统处理,
// 不能被误判成页面预览路由,否则会返回 HTML 导致模块加载失败。
if (looksLikeFileRequest(subPath)) {
return null;
}
if (resolved.restParts.length === 0 || !isSpecRoute) {
// /themes/{name} 或 /themes/{name}/index.html
return {
type: 'themes',
name: resolved.name,
action: 'preview',
isLegacy: isLegacyIndexRoute,
originalUrl: url,
normalizedUrl: `${encodeRoutePath(`/themes/${resolved.name}${subPath ? `/${subPath}` : ''}`)}${versionId ? `?ver=${versionId}` : ''}`,
versionId,
subPath: subPath || undefined,
};
} else if (isSpecRoute) {
// /themes/{name}/spec 或 /themes/{name}/spec.html
return {
type: 'themes',
name: resolved.name,
action: 'spec',
isLegacy: urlWithoutQuery.includes('.html'),
originalUrl: url,
normalizedUrl: `${encodeRoutePath(`/themes/${resolved.name}/spec`)}${versionId ? `?ver=${versionId}` : ''}`,
versionId
};
}
}
// 情况 4: /docs/{name} 或 /docs/{name}/spec.html
if (pathParts[0] === 'docs' && pathParts.length >= 2) {
const nameParts = decodePathSegments(pathParts.slice(1));
const lastPart = nameParts[nameParts.length - 1];
if (lastPart === 'spec') {
// /docs/{name}/spec.html旧格式→ /docs/{name}
const name = nameParts.slice(0, -1).join('/');
return {
type: 'docs',
name,
action: 'spec',
isLegacy: true,
originalUrl: url,
normalizedUrl: encodeRoutePath(`/docs/${name}`),
versionId
};
}
// /docs/{name}
const name = nameParts.join('/');
return {
type: 'docs',
name,
action: 'spec',
isLegacy: false,
originalUrl: url,
normalizedUrl: encodeRoutePath(`/docs/${name}`),
versionId
};
}
// 情况 5: /assets/docs/{name} 或 /assets/docs/{name}/spec.html旧格式兼容
if (pathParts[0] === 'assets' && pathParts[1] === 'docs' && pathParts.length >= 3) {
const nameParts = decodePathSegments(pathParts.slice(2));
const lastPart = nameParts[nameParts.length - 1];
if (lastPart === 'spec') {
// /assets/docs/{name}/spec.html旧格式→ /docs/{name}
const name = nameParts.slice(0, -1).join('/');
return {
type: 'docs',
name,
action: 'spec',
isLegacy: true,
originalUrl: url,
normalizedUrl: encodeRoutePath(`/docs/${name}`),
versionId
};
}
// /assets/docs/{name}(旧格式)→ /docs/{name}
const name = nameParts.join('/');
return {
type: 'docs',
name,
action: 'spec',
isLegacy: true,
originalUrl: url,
normalizedUrl: encodeRoutePath(`/docs/${name}`),
versionId
};
}
// 情况 6: /{name}.html 或 /{name}/spec.html旧格式需要查找是 page 还是 element
if (pathParts.length === 1 && urlWithoutQuery.endsWith('.html')) {
const name = safeDecodeURIComponent(pathParts[0]);
const type = resolveEntryTypeByName(name);
if (type) {
return {
type,
name,
action: 'preview',
isLegacy: true,
originalUrl: url,
normalizedUrl: `${encodeRoutePath(`/${type}/${name}`)}${versionId ? `?ver=${versionId}` : ''}`,
versionId
};
}
}
// 情况 7: /{name}/spec.html旧格式
if (pathParts.length === 2 && pathParts[1] === 'spec' && urlWithoutQuery.endsWith('.html')) {
const name = safeDecodeURIComponent(pathParts[0]);
const type = resolveEntryTypeByName(name);
if (type) {
return {
type,
name,
action: 'spec',
isLegacy: true,
originalUrl: url,
normalizedUrl: `${encodeRoutePath(`/${type}/${name}/spec`)}${versionId ? `?ver=${versionId}` : ''}`,
versionId
};
}
}
return null;
}
/**
* 处理路径重定向(旧格式 → 新格式)
*/
export function handlePathRedirect(req: IncomingMessage, res: ServerResponse): boolean {
if (!req.url) return false;
const normalized = normalizePath(req.url);
if (
normalized &&
!normalized.isLegacy &&
normalized.action === 'preview'
) {
const htmlEntryPath = path.resolve(process.cwd(), 'src', normalized.type, normalized.name, 'index.html');
if (fs.existsSync(htmlEntryPath) && !normalized.subPath) {
const params = new URLSearchParams(req.url.split('?')[1] || '');
const query = params.toString();
const redirectUrl = `${encodeRoutePath(`/${normalized.type}/${normalized.name}/index.html`)}${query ? `?${query}` : ''}`;
res.statusCode = 302;
res.setHeader('Location', redirectUrl);
res.end();
return true;
}
}
if (normalized && normalized.isLegacy) {
if (normalized.action === 'preview') {
const htmlEntryPath = path.resolve(process.cwd(), 'src', normalized.type, normalized.name, 'index.html');
if (fs.existsSync(htmlEntryPath)) {
return false;
}
}
// 旧格式,重定向到新格式
logVirtualHtmlDebug('路径重定向:', normalized.originalUrl, '→', normalized.normalizedUrl);
res.statusCode = 301; // 永久重定向
res.setHeader('Location', normalized.normalizedUrl);
res.end();
return true;
}
return false;
}