refactor: 模块化重构 generator 和 runtime

This commit is contained in:
rbetree
2026-01-15 21:08:26 +08:00
parent bcfa6e6316
commit 1a90f8fbe3
38 changed files with 4881 additions and 4411 deletions

403
src/runtime/app/routing.js Normal file
View File

@@ -0,0 +1,403 @@
const nested = require('../nested');
module.exports = function initRouting(state, dom, api) {
const { ui, search } = api;
const { searchInput, content } = dom;
function showPage(pageId, skipSearchReset = false) {
if (state.currentPageId === pageId && !skipSearchReset && !state.isInitialLoad) return;
state.currentPageId = pageId;
// 使用 RAF 确保动画流畅
requestAnimationFrame(() => {
if (!state.pages) {
state.pages = document.querySelectorAll('.page');
}
state.pages.forEach((page) => {
const shouldBeActive = page.id === pageId;
if (shouldBeActive !== page.classList.contains('active')) {
page.classList.toggle('active', shouldBeActive);
}
});
// 初始加载完成后设置标志
if (state.isInitialLoad) {
state.isInitialLoad = false;
document.body.classList.add('loaded');
}
});
// 重置滚动位置并更新进度条
content.scrollTop = 0;
// 只有在非搜索状态下才重置搜索
if (!skipSearchReset) {
searchInput.value = '';
search.resetSearch();
}
}
// 初始化(在 window load 时执行)
window.addEventListener('load', () => {
// 获取可能在 HTML 生成后才存在的 DOM 元素
const categories = document.querySelectorAll('.category');
const navItems = document.querySelectorAll('.nav-item');
const navItemWrappers = document.querySelectorAll('.nav-item-wrapper');
const submenuItems = document.querySelectorAll('.submenu-item');
state.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;
};
// 初始化主题
ui.initTheme();
// 初始化侧边栏状态
ui.initSidebarState();
// 初始化搜索引擎选择
search.initSearchEngine();
// 初始化 MeNav 对象版本信息
try {
const config = window.MeNav.getConfig();
if (config && config.version) {
window.MeNav.version = config.version;
console.log('MeNav API initialized with version:', config.version);
}
} catch (error) {
console.error('Error initializing MeNav API:', error);
}
// 立即执行初始化,不再使用 requestAnimationFrame 延迟
// 支持 ?page=<id> 直接打开对应页面;无效时回退到首页
const rawPageIdFromUrl = getRawPageIdFromUrl();
const validatedPageIdFromUrl = getPageIdFromUrl();
const initialPageId = validatedPageIdFromUrl || (isValidPageId(state.homePageId) ? state.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) => {
setTimeout(() => {
category.style.opacity = '1';
}, index * 100);
});
// 初始展开当前页面的子菜单:高亮项如果有子菜单,需要同步展开
document.querySelectorAll('.nav-item.active').forEach((activeItem) => {
const activeWrapper = activeItem.closest('.nav-item-wrapper');
if (!activeWrapper) return;
const hasSubmenu = activeWrapper.querySelector('.submenu');
if (hasSubmenu) {
activeWrapper.classList.add('expanded');
}
});
// 导航项点击效果
navItems.forEach((item) => {
item.addEventListener('click', (e) => {
if (item.getAttribute('target') === '_blank') return;
e.preventDefault();
// 获取当前项的父级 wrapper
const wrapper = item.closest('.nav-item-wrapper');
const hasSubmenu = wrapper && wrapper.querySelector('.submenu');
// 处理子菜单展开/折叠
if (hasSubmenu) {
// 如果点击的导航项已经激活且有子菜单,则切换子菜单展开状态
if (item.classList.contains('active')) {
wrapper.classList.toggle('expanded');
} else {
// 关闭所有已展开的子菜单
navItemWrappers.forEach((navWrapper) => {
if (navWrapper !== wrapper) {
navWrapper.classList.remove('expanded');
}
});
// 展开当前子菜单
wrapper.classList.add('expanded');
}
}
// 激活导航项
navItems.forEach((nav) => {
nav.classList.toggle('active', nav === item);
});
const pageId = item.getAttribute('data-page');
if (pageId) {
const prevPageId = state.currentPageId;
showPage(pageId);
// 切换页面时同步 URL清空旧 hash避免跨页残留
if (normalizeText(prevPageId) !== normalizeText(pageId)) {
setUrlState({ pageId, hash: '' }, { replace: true });
}
// 在移动端视图下点击导航项后自动收起侧边栏
if (ui.isMobile() && state.isSidebarOpen && !hasSubmenu) {
ui.closeAllPanels();
}
}
});
});
// 子菜单项点击效果
submenuItems.forEach((item) => {
item.addEventListener('click', (e) => {
e.preventDefault();
// 获取页面 ID 和分类名称
const pageId = item.getAttribute('data-page');
const categoryName = item.getAttribute('data-category');
const categoryId = item.getAttribute('data-category-id');
if (pageId) {
// 清除所有子菜单项的激活状态
submenuItems.forEach((subItem) => {
subItem.classList.remove('active');
});
// 激活当前子菜单项
item.classList.add('active');
// 激活相应的导航项
navItems.forEach((nav) => {
nav.classList.toggle('active', nav.getAttribute('data-page') === pageId);
});
// 显示对应页面
showPage(pageId);
// 先同步 page 参数并清空旧 hash避免跨页残留后续若找到分类再写入新的 hash
setUrlState({ pageId, hash: '' }, { replace: true });
// 等待页面切换完成后滚动到对应分类
setTimeout(() => {
const found = scrollToCategoryInPage(pageId, { categoryId, categoryName });
if (!found) return;
// 由于对子菜单 click 做了 preventDefault这里手动同步 hash不触发浏览器默认跳转
const nextHash = normalizeText(categoryId) || normalizeText(categoryName);
if (nextHash) {
setUrlState({ pageId, hash: nextHash }, { replace: true });
}
}, 25); // 延迟时间
// 在移动端视图下点击子菜单项后自动收起侧边栏
if (ui.isMobile() && state.isSidebarOpen) {
ui.closeAllPanels();
}
}
});
});
// 初始化嵌套分类功能
nested.initializeNestedCategories();
// 初始化分类切换按钮
const categoryToggleBtn = document.getElementById('category-toggle');
if (categoryToggleBtn) {
categoryToggleBtn.addEventListener('click', function () {
window.MeNav.toggleCategories();
});
} else {
console.error('Category toggle button not found');
}
// 初始化搜索索引(使用 requestIdleCallback 或 setTimeout 延迟初始化,避免影响页面加载)
if ('requestIdleCallback' in window) {
requestIdleCallback(() => search.initSearchIndex());
} else {
setTimeout(search.initSearchIndex, 1000);
}
});
return { showPage };
};