feat(router): 支持?page=深链接&错误路由回退

- 导航/子菜单 href 统一为 ?page=<id>[#<slug>],支持复制/新开直达
- 启动时解析 ?page= 并同步导航高亮;子菜单跳转可组合 hash 定位分类
- 输入无效 pageId 时自动纠正 URL 到首页(replaceState,避免“内容回退但地址栏仍错误”)
- 构建生成 dist/404.html:将 /<id>(或 /<repo>/<id>)回跳到 /?page=<id> 并保留 hash
This commit is contained in:
rbetree
2026-01-07 17:29:48 +08:00
parent 1d158aabd7
commit efd1683e2b
5 changed files with 340 additions and 82 deletions

View File

@@ -1721,6 +1721,116 @@ function copyStaticFiles(config) {
} }
} }
// 生成 GitHub Pages 的 404 回跳页:将 /<id> 形式的路径深链接转换为 /?page=<id>
function generate404Html(config) {
const siteTitle =
config && config.site && typeof config.site.title === 'string' ? config.site.title : 'MeNav';
const safeTitle = escapeHtml(siteTitle);
return `<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="robots" content="noindex" />
<title>${safeTitle} - 页面未找到</title>
<style>
body {
margin: 0;
padding: 40px 16px;
font-family: system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue',
Arial, 'Noto Sans', 'Liberation Sans', sans-serif;
color: #111;
background: #fff;
}
.container {
max-width: 720px;
margin: 0 auto;
}
h1 {
margin: 0 0 12px;
font-size: 22px;
line-height: 1.3;
}
p {
margin: 8px 0;
line-height: 1.6;
}
a {
color: #2563eb;
text-decoration: none;
}
a:hover {
text-decoration: underline;
}
code {
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono',
'Courier New', monospace;
}
</style>
<script>
(function () {
var l = window.location;
var pathname = (l && l.pathname) || '';
var hash = (l && l.hash) || '';
var search = (l && l.search) || '';
// 清理首尾 /,并分割路径段
var segments = pathname.replace(/^\\/+|\\/+$/g, '').split('/').filter(Boolean);
// 仅处理两种常见形态:
// - 用户站点:/<id>
// - 仓库站点:/<repo>/<id>
var base = '/';
var pageId = '';
if (segments.length === 1) {
pageId = segments[0];
} else if (segments.length === 2) {
base = '/' + segments[0] + '/';
pageId = segments[1];
} else {
return;
}
// 过滤明显不是“页面 id”的情况静态资源/404 本身)
if (!pageId || pageId === '404.html' || pageId.indexOf('.') !== -1) return;
try {
pageId = decodeURIComponent(pageId);
} catch (e) {
// 保持原值
}
// 将原 search 附加到目标 URL去掉可能存在的 page 参数,避免冲突)
var extra = '';
if (search && search.length > 1) {
try {
var params = new URLSearchParams(search);
params.delete('page');
var rest = params.toString();
if (rest) extra = '&' + rest;
} catch (e) {
// URLSearchParams 不可用时直接忽略
}
}
var target = base + '?page=' + encodeURIComponent(pageId) + extra + hash;
l.replace(target);
})();
</script>
</head>
<body>
<div class="container">
<h1>页面未找到</h1>
<p>若你访问的是“页面路径深链接”,系统将自动回跳到 <code>?page=</code> 形式的可用地址。</p>
<p><a href="./">返回首页</a></p>
</div>
</body>
</html>
`;
}
// 主函数 // 主函数
function main() { function main() {
const config = loadConfig(); const config = loadConfig();
@@ -1740,6 +1850,9 @@ function main() {
// 生成HTML // 生成HTML
fs.writeFileSync('dist/index.html', htmlContent); fs.writeFileSync('dist/index.html', htmlContent);
// GitHub Pages 静态路由回退:用于支持 /<id> 形式的路径深链接
fs.writeFileSync('dist/404.html', generate404Html(config));
// 复制静态文件 // 复制静态文件
copyStaticFiles(config); copyStaticFiles(config);
} catch (e) { } catch (e) {
@@ -1756,6 +1869,7 @@ if (require.main === module) {
module.exports = { module.exports = {
loadConfig, loadConfig,
generateHTML, generateHTML,
generate404Html,
copyStaticFiles, copyStaticFiles,
generateNavigation, generateNavigation,
generateCategories, generateCategories,

View File

@@ -1886,6 +1886,172 @@ document.addEventListener('DOMContentLoaded', () => {
const submenuItems = document.querySelectorAll('.submenu-item'); const submenuItems = document.querySelectorAll('.submenu-item');
pages = document.querySelectorAll('.page'); pages = document.querySelectorAll('.page');
// 方案 A用 ?page=<id> 作为页面深链接(兼容 GitHub Pages 静态托管)
const normalizeText = (value) =>
String(value === null || value === undefined ? '' : value).trim();
const isValidPageId = (pageId) => {
const id = normalizeText(pageId);
if (!id) return false;
const el = document.getElementById(id);
return Boolean(el && el.classList && el.classList.contains('page'));
};
const getRawPageIdFromUrl = () => {
try {
const url = new URL(window.location.href);
return normalizeText(url.searchParams.get('page'));
} catch (error) {
return '';
}
};
const getPageIdFromUrl = () => {
try {
const url = new URL(window.location.href);
const pageId = normalizeText(url.searchParams.get('page'));
return isValidPageId(pageId) ? pageId : '';
} catch (error) {
return '';
}
};
const setUrlState = (next, options = {}) => {
const { replace = true } = options;
try {
const url = new URL(window.location.href);
if (next && typeof next.pageId === 'string') {
const pageId = normalizeText(next.pageId);
if (pageId) {
url.searchParams.set('page', pageId);
} else {
url.searchParams.delete('page');
}
}
if (next && Object.prototype.hasOwnProperty.call(next, 'hash')) {
const hash = normalizeText(next.hash);
url.hash = hash ? `#${hash}` : '';
}
const nextUrl = `${url.pathname}${url.search}${url.hash}`;
const fn = replace ? history.replaceState : history.pushState;
fn.call(history, null, '', nextUrl);
} catch (error) {
// 忽略 URL/History API 异常,避免影响主流程
}
};
const setActiveNavByPageId = (pageId) => {
const id = normalizeText(pageId);
let activeItem = null;
navItems.forEach((nav) => {
const isActive = nav.getAttribute('data-page') === id;
nav.classList.toggle('active', isActive);
if (isActive) activeItem = nav;
});
// 同步子菜单展开状态:只展开当前激活项
navItemWrappers.forEach((wrapper) => {
const nav = wrapper.querySelector('.nav-item');
if (!nav) return;
const hasSubmenu = Boolean(wrapper.querySelector('.submenu'));
const shouldExpand = hasSubmenu && nav === activeItem;
wrapper.classList.toggle('expanded', shouldExpand);
});
};
const escapeSelector = (value) => {
if (value === null || value === undefined) return '';
const text = String(value);
if (window.CSS && typeof window.CSS.escape === 'function') return window.CSS.escape(text);
// 回退:尽量避免打断选择器(不追求完全覆盖所有边界字符)
return text.replace(/[^a-zA-Z0-9_\u00A0-\uFFFF-]/g, '\\$&');
};
const escapeAttrValue = (value) => {
if (value === null || value === undefined) return '';
return String(value).replace(/\\/g, '\\\\').replace(/"/g, '\\"');
};
const getHashFromUrl = () => {
const rawHash = window.location.hash ? String(window.location.hash).slice(1) : '';
if (!rawHash) return '';
try {
return decodeURIComponent(rawHash).trim();
} catch (error) {
return rawHash.trim();
}
};
const scrollToCategoryInPage = (pageId, options = {}) => {
const id = normalizeText(pageId);
if (!id) return false;
const targetPage = document.getElementById(id);
if (!targetPage) return false;
const categoryId = normalizeText(options.categoryId);
const categoryName = normalizeText(options.categoryName);
let targetCategory = null;
// 优先使用 slug/data-id 精准定位(解决重复命名始终命中第一个的问题)
if (categoryId) {
const escapedId = escapeSelector(categoryId);
targetCategory =
targetPage.querySelector(`#${escapedId}`) ||
targetPage.querySelector(
`[data-type="category"][data-id="${escapeAttrValue(categoryId)}"]`
);
}
// 回退:旧逻辑按文本包含匹配(兼容旧页面/旧数据)
if (!targetCategory && categoryName) {
targetCategory = Array.from(targetPage.querySelectorAll('.category h2')).find((heading) =>
heading.textContent.trim().includes(categoryName)
);
}
if (!targetCategory) return false;
// 优化的滚动实现滚动到使目标分类位于视口1/4处更靠近顶部位置
try {
// 直接获取所需元素和属性,减少重复查询
const contentElement = document.querySelector('.content');
if (contentElement && contentElement.scrollHeight > contentElement.clientHeight) {
// 获取目标元素相对于内容区域的位置
const rect = targetCategory.getBoundingClientRect();
const containerRect = contentElement.getBoundingClientRect();
// 计算目标应该在视口中的位置视口高度的1/4处
const desiredPosition = containerRect.height / 4;
// 计算需要滚动的位置
const scrollPosition =
contentElement.scrollTop + rect.top - containerRect.top - desiredPosition;
// 执行滚动
contentElement.scrollTo({
top: scrollPosition,
behavior: 'smooth',
});
} else {
// 回退到基本滚动方式
targetCategory.scrollIntoView({ behavior: 'smooth', block: 'center' });
}
} catch (error) {
console.error('Error during scroll:', error);
// 回退到基本滚动方式
targetCategory.scrollIntoView({ behavior: 'smooth', block: 'center' });
}
return true;
};
// 初始化主题 // 初始化主题
initTheme(); initTheme();
@@ -1907,8 +2073,32 @@ document.addEventListener('DOMContentLoaded', () => {
} }
// 立即执行初始化不再使用requestAnimationFrame延迟 // 立即执行初始化不再使用requestAnimationFrame延迟
// 显示首页 // 支持 ?page=<id> 直接打开对应页面;无效时回退到首页
showPage(homePageId); const rawPageIdFromUrl = getRawPageIdFromUrl();
const validatedPageIdFromUrl = getPageIdFromUrl();
const initialPageId = validatedPageIdFromUrl || (isValidPageId(homePageId) ? homePageId : 'home');
setActiveNavByPageId(initialPageId);
showPage(initialPageId);
// 当输入了不存在的 page id 时,自动纠正 URL避免“内容回退但地址栏仍错误”
if (rawPageIdFromUrl && !validatedPageIdFromUrl) {
setUrlState({ pageId: initialPageId }, { replace: true });
}
// 初始深链接:支持 ?page=<id>#<categorySlug>
const initialHash = getHashFromUrl();
if (initialHash) {
setTimeout(() => {
const found = scrollToCategoryInPage(initialPageId, {
categoryId: initialHash,
categoryName: initialHash,
});
// hash 存在但未命中时,不做强制修正,避免误伤其他用途的 hash
if (!found) return;
}, 50);
}
// 添加载入动画 // 添加载入动画
categories.forEach((category, index) => { categories.forEach((category, index) => {
@@ -1964,7 +2154,12 @@ document.addEventListener('DOMContentLoaded', () => {
const pageId = item.getAttribute('data-page'); const pageId = item.getAttribute('data-page');
if (pageId) { if (pageId) {
const prevPageId = currentPageId;
showPage(pageId); showPage(pageId);
// 切换页面时同步 URL清空旧 hash避免跨页残留
if (normalizeText(prevPageId) !== normalizeText(pageId)) {
setUrlState({ pageId, hash: '' }, { replace: true });
}
// 在移动端视图下点击导航项后自动收起侧边栏 // 在移动端视图下点击导航项后自动收起侧边栏
if (isMobile() && isSidebarOpen && !hasSubmenu) { if (isMobile() && isSidebarOpen && !hasSubmenu) {
@@ -1984,19 +2179,6 @@ document.addEventListener('DOMContentLoaded', () => {
const categoryName = item.getAttribute('data-category'); const categoryName = item.getAttribute('data-category');
const categoryId = item.getAttribute('data-category-id'); const categoryId = item.getAttribute('data-category-id');
const escapeSelector = (value) => {
if (value === null || value === undefined) return '';
const text = String(value);
if (window.CSS && typeof window.CSS.escape === 'function') return window.CSS.escape(text);
// 回退:尽量避免打断选择器(不追求完全覆盖所有边界字符)
return text.replace(/[^a-zA-Z0-9_\u00A0-\uFFFF-]/g, '\\$&');
};
const escapeAttrValue = (value) => {
if (value === null || value === undefined) return '';
return String(value).replace(/\\/g, '\\\\').replace(/"/g, '\\"');
};
if (pageId) { if (pageId) {
// 清除所有子菜单项的激活状态 // 清除所有子菜单项的激活状态
submenuItems.forEach((subItem) => { submenuItems.forEach((subItem) => {
@@ -2013,74 +2195,18 @@ document.addEventListener('DOMContentLoaded', () => {
// 显示对应页面 // 显示对应页面
showPage(pageId); showPage(pageId);
// 先同步 page 参数并清空旧 hash避免跨页残留后续若找到分类再写入新的 hash
setUrlState({ pageId, hash: '' }, { replace: true });
// 等待页面切换完成后滚动到对应分类 // 等待页面切换完成后滚动到对应分类
setTimeout(() => { setTimeout(() => {
// 查找目标分类元素 const found = scrollToCategoryInPage(pageId, { categoryId, categoryName });
const targetPage = document.getElementById(pageId); if (!found) return;
if (targetPage) {
let targetCategory = null;
// 优先使用 slug/data-id 精准定位(解决重复命名始终命中第一个的问题)
if (categoryId) {
const escapedId = escapeSelector(categoryId);
targetCategory =
targetPage.querySelector(`#${escapedId}`) ||
targetPage.querySelector(
`[data-type="category"][data-id="${escapeAttrValue(categoryId)}"]`
);
}
// 回退:旧逻辑按文本包含匹配(兼容旧页面/旧数据)
if (!targetCategory && categoryName) {
targetCategory = Array.from(targetPage.querySelectorAll('.category h2')).find(
(heading) => heading.textContent.trim().includes(categoryName)
);
}
if (targetCategory) {
// 由于对子菜单 click 做了 preventDefault这里手动同步 hash不触发浏览器默认跳转 // 由于对子菜单 click 做了 preventDefault这里手动同步 hash不触发浏览器默认跳转
const nextHash = categoryId || categoryName; const nextHash = normalizeText(categoryId) || normalizeText(categoryName);
if (nextHash) { if (nextHash) {
try { setUrlState({ pageId, hash: nextHash }, { replace: true });
history.replaceState(null, '', `#${nextHash}`);
} catch (error) {
// 忽略 history API 失败,避免影响滚动体验
}
}
// 优化的滚动实现滚动到使目标分类位于视口1/4处更靠近顶部位置
try {
// 直接获取所需元素和属性,减少重复查询
const contentElement = document.querySelector('.content');
if (contentElement && contentElement.scrollHeight > contentElement.clientHeight) {
// 获取目标元素相对于内容区域的位置
const rect = targetCategory.getBoundingClientRect();
const containerRect = contentElement.getBoundingClientRect();
// 计算目标应该在视口中的位置视口高度的1/4处
const desiredPosition = containerRect.height / 4;
// 计算需要滚动的位置
const scrollPosition =
contentElement.scrollTop + rect.top - containerRect.top - desiredPosition;
// 执行滚动
contentElement.scrollTo({
top: scrollPosition,
behavior: 'smooth',
});
} else {
// 回退到基本滚动方式
targetCategory.scrollIntoView({ behavior: 'smooth', block: 'center' });
}
} catch (error) {
console.error('Error during scroll:', error);
// 回退到基本滚动方式
targetCategory.scrollIntoView({ behavior: 'smooth', block: 'center' });
}
}
} }
}, 25); // 延迟时间 }, 25); // 延迟时间

View File

@@ -1,7 +1,7 @@
<div data-container="nav-items"> <div data-container="nav-items">
{{#each this}} {{#each this}}
<div class="nav-item-wrapper"> <div class="nav-item-wrapper">
<a href="#" class="nav-item{{#if isActive}} active{{/if}}{{#if active}} active{{/if}}" data-page="{{id}}" data-type="nav-item" data-id="{{id}}" data-name="{{name}}" data-icon="{{icon}}"> <a href="?page={{id}}" class="nav-item{{#if isActive}} active{{/if}}{{#if active}} active{{/if}}" data-page="{{id}}" data-type="nav-item" data-id="{{id}}" data-name="{{name}}" data-icon="{{icon}}">
<div class="icon-container"> <div class="icon-container">
<i class="{{icon}}"></i> <i class="{{icon}}"></i>
</div> </div>
@@ -11,7 +11,7 @@
{{#if submenu}} {{#if submenu}}
<div class="submenu"> <div class="submenu">
{{#each submenu}} {{#each submenu}}
<a href="#{{#if slug}}{{slug}}{{else}}{{name}}{{/if}}" class="submenu-item" data-page="{{../id}}" data-category="{{name}}" data-category-id="{{#if slug}}{{slug}}{{else}}{{name}}{{/if}}"> <a href="?page={{../id}}#{{#if slug}}{{slug}}{{else}}{{name}}{{/if}}" class="submenu-item" data-page="{{../id}}" data-category="{{name}}" data-category-id="{{#if slug}}{{slug}}{{else}}{{name}}{{/if}}">
<i class="{{icon}}"></i> <i class="{{icon}}"></i>
<span>{{name}}</span> <span>{{name}}</span>
</a> </a>

View File

@@ -0,0 +1,16 @@
const test = require('node:test');
const assert = require('node:assert/strict');
const { generate404Html } = require('../src/generator.js');
test('P1-5404.html 回跳应将 /<id> 转为 ?page=<id>(并支持仓库前缀)', () => {
const html = generate404Html({ site: { title: 'Test Site' } });
assert.ok(typeof html === 'string' && html.length > 0);
assert.ok(html.includes('?page='), '应包含 ?page= 形态');
assert.ok(html.includes('encodeURIComponent(pageId)'), '应对 pageId 做 URL 编码');
assert.ok(html.includes('segments.length === 1'), '应支持用户站点 /<id>');
assert.ok(html.includes('segments.length === 2'), '应支持仓库站点 /<repo>/<id>');
assert.ok(html.includes('l.replace(target)'), '应使用 location.replace 执行回跳');
});

View File

@@ -37,7 +37,9 @@ test('P1-2子菜单锚点应使用分类 slughref + data-category-id',
const html = generateHTML(config); const html = generateHTML(config);
assert.ok(html.includes('class="submenu-item"'), '应输出子菜单项'); assert.ok(html.includes('class="submenu-item"'), '应输出子菜单项');
assert.ok(html.includes('href="#重复-分类"'), '子菜单 href 应指向 slug'); assert.ok(html.includes('href="?page=home#重复-分类"'), '子菜单 href 应支持 ?page=<id>#<slug>');
assert.ok(html.includes('data-category-id="重复-分类"'), '子菜单应携带 data-category-id'); assert.ok(html.includes('data-category-id="重复-分类"'), '子菜单应携带 data-category-id');
assert.ok(html.includes('class="nav-item'), '应输出导航项');
assert.ok(html.includes('href="?page=home"'), '导航项 href 应支持 ?page=<id> 深链接');
}); });
}); });