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:
114
src/generator.js
114
src/generator.js
@@ -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() {
|
||||
const config = loadConfig();
|
||||
@@ -1740,6 +1850,9 @@ function main() {
|
||||
// 生成HTML
|
||||
fs.writeFileSync('dist/index.html', htmlContent);
|
||||
|
||||
// GitHub Pages 静态路由回退:用于支持 /<id> 形式的路径深链接
|
||||
fs.writeFileSync('dist/404.html', generate404Html(config));
|
||||
|
||||
// 复制静态文件
|
||||
copyStaticFiles(config);
|
||||
} catch (e) {
|
||||
@@ -1756,6 +1869,7 @@ if (require.main === module) {
|
||||
module.exports = {
|
||||
loadConfig,
|
||||
generateHTML,
|
||||
generate404Html,
|
||||
copyStaticFiles,
|
||||
generateNavigation,
|
||||
generateCategories,
|
||||
|
||||
284
src/script.js
284
src/script.js
@@ -1886,6 +1886,172 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
const submenuItems = document.querySelectorAll('.submenu-item');
|
||||
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();
|
||||
|
||||
@@ -1907,8 +2073,32 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
}
|
||||
|
||||
// 立即执行初始化,不再使用requestAnimationFrame延迟
|
||||
// 显示首页
|
||||
showPage(homePageId);
|
||||
// 支持 ?page=<id> 直接打开对应页面;无效时回退到首页
|
||||
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) => {
|
||||
@@ -1964,7 +2154,12 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
|
||||
const pageId = item.getAttribute('data-page');
|
||||
if (pageId) {
|
||||
const prevPageId = currentPageId;
|
||||
showPage(pageId);
|
||||
// 切换页面时同步 URL(清空旧 hash,避免跨页残留)
|
||||
if (normalizeText(prevPageId) !== normalizeText(pageId)) {
|
||||
setUrlState({ pageId, hash: '' }, { replace: true });
|
||||
}
|
||||
|
||||
// 在移动端视图下点击导航项后自动收起侧边栏
|
||||
if (isMobile() && isSidebarOpen && !hasSubmenu) {
|
||||
@@ -1984,19 +2179,6 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
const categoryName = item.getAttribute('data-category');
|
||||
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) {
|
||||
// 清除所有子菜单项的激活状态
|
||||
submenuItems.forEach((subItem) => {
|
||||
@@ -2013,74 +2195,18 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
|
||||
// 显示对应页面
|
||||
showPage(pageId);
|
||||
// 先同步 page 参数并清空旧 hash,避免跨页残留;后续若找到分类再写入新的 hash
|
||||
setUrlState({ pageId, hash: '' }, { replace: true });
|
||||
|
||||
// 等待页面切换完成后滚动到对应分类
|
||||
setTimeout(() => {
|
||||
// 查找目标分类元素
|
||||
const targetPage = document.getElementById(pageId);
|
||||
if (targetPage) {
|
||||
let targetCategory = null;
|
||||
const found = scrollToCategoryInPage(pageId, { categoryId, categoryName });
|
||||
if (!found) return;
|
||||
|
||||
// 优先使用 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(不触发浏览器默认跳转)
|
||||
const nextHash = categoryId || categoryName;
|
||||
if (nextHash) {
|
||||
try {
|
||||
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' });
|
||||
}
|
||||
}
|
||||
// 由于对子菜单 click 做了 preventDefault,这里手动同步 hash(不触发浏览器默认跳转)
|
||||
const nextHash = normalizeText(categoryId) || normalizeText(categoryName);
|
||||
if (nextHash) {
|
||||
setUrlState({ pageId, hash: nextHash }, { replace: true });
|
||||
}
|
||||
}, 25); // 延迟时间
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<div data-container="nav-items">
|
||||
{{#each this}}
|
||||
<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">
|
||||
<i class="{{icon}}"></i>
|
||||
</div>
|
||||
@@ -11,7 +11,7 @@
|
||||
{{#if submenu}}
|
||||
<div class="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>
|
||||
<span>{{name}}</span>
|
||||
</a>
|
||||
|
||||
16
test/404-fallback.node-test.js
Normal file
16
test/404-fallback.node-test.js
Normal file
@@ -0,0 +1,16 @@
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
|
||||
const { generate404Html } = require('../src/generator.js');
|
||||
|
||||
test('P1-5:404.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 执行回跳');
|
||||
});
|
||||
|
||||
@@ -37,7 +37,9 @@ test('P1-2:子菜单锚点应使用分类 slug(href + data-category-id)',
|
||||
const html = generateHTML(config);
|
||||
|
||||
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('class="nav-item'), '应输出导航项');
|
||||
assert.ok(html.includes('href="?page=home"'), '导航项 href 应支持 ?page=<id> 深链接');
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user