fix: 修复外部资源、图标模式与嵌套交互(#30)

Fixes: https://github.com/rbetree/menav/issues/30

- Font Awesome:bootcdn→Cloudflare cdnjs
- favicon:gstatic `.com` 失败自动回退 `.cn`
- `icons.mode`:修复 `site.yml` 配置未生效(提升到顶层)
- 站点级图标覆盖:支持 `faviconUrl` / `forceIconMode`(优先级:`faviconUrl` > `forceIconMode` > `icons.mode`)
- 折叠交互:恢复二级分组折叠按钮(桌面端悬停显示)
- 新标签页:递归补齐多级 `sites.external` 默认值
This commit is contained in:
rbetree
2026-01-02 14:58:53 +08:00
parent d2ceeb674f
commit 30d50ebcd7
13 changed files with 613 additions and 97 deletions

View File

@@ -248,6 +248,8 @@ function loadModularConfig(dirPath) {
if (siteConfig.fonts) config.fonts = siteConfig.fonts;
if (siteConfig.profile) config.profile = siteConfig.profile;
if (siteConfig.social) config.social = siteConfig.social;
// 图标配置icons.mode需要作为顶层字段供模板/运行时读取
if (siteConfig.icons) config.icons = siteConfig.icons;
// 优先使用site.yml中的navigation配置
if (siteConfig.navigation) {
@@ -334,11 +336,24 @@ function ensureConfigDefaults(config) {
site.external = typeof site.external === 'boolean' ? site.external : true;
}
// 递归处理多级结构categories/subcategories/groups/subgroups下的 sites 默认值
function processNodeSitesRecursively(node) {
if (!node || typeof node !== 'object') return;
if (Array.isArray(node.sites)) {
node.sites.forEach(processSiteDefaults);
}
if (Array.isArray(node.subcategories)) node.subcategories.forEach(processNodeSitesRecursively);
if (Array.isArray(node.groups)) node.groups.forEach(processNodeSitesRecursively);
if (Array.isArray(node.subgroups)) node.subgroups.forEach(processNodeSitesRecursively);
}
// 处理分类默认值的辅助函数
function processCategoryDefaults(category) {
category.name = category.name || '未命名分类';
category.sites = category.sites || [];
category.sites.forEach(processSiteDefaults);
processNodeSitesRecursively(category);
}
// 为所有页面配置中的类别和站点设置默认值
@@ -1465,6 +1480,62 @@ function copyStaticFiles(config) {
console.error('Error copying script.js:', e);
}
// faviconUrl站点级自定义图标若使用本地路径建议以 assets/ 开头),则复制到 dist 下同路径
try {
const copied = new Set();
const copyLocalAsset = (rawUrl) => {
const raw = String(rawUrl || '').trim();
if (!raw) return;
if (/^https?:\/\//i.test(raw)) return;
const rel = raw.replace(/\\/g, '/').replace(/^\.\//, '').replace(/^\//, '');
if (!rel.startsWith('assets/')) return;
const normalized = path.posix.normalize(rel);
if (!normalized.startsWith('assets/')) return;
if (copied.has(normalized)) return;
copied.add(normalized);
const srcPath = path.join(process.cwd(), normalized);
const destPath = path.join(process.cwd(), 'dist', normalized);
if (!fs.existsSync(srcPath)) {
console.warn(`[WARN] faviconUrl 本地文件不存在:${normalized}`);
return;
}
fs.mkdirSync(path.dirname(destPath), { recursive: true });
fs.copyFileSync(srcPath, destPath);
};
if (config && Array.isArray(config.navigation)) {
config.navigation.forEach(navItem => {
const pageId = navItem && navItem.id ? String(navItem.id) : '';
if (!pageId) return;
const pageConfig = config[pageId];
if (!pageConfig || typeof pageConfig !== 'object') return;
if (Array.isArray(pageConfig.sites)) {
pageConfig.sites.forEach(site => {
if (!site || typeof site !== 'object') return;
copyLocalAsset(site.faviconUrl);
});
}
if (Array.isArray(pageConfig.categories)) {
const sites = [];
pageConfig.categories.forEach(category => collectSitesRecursively(category, sites));
sites.forEach(site => {
if (!site || typeof site !== 'object') return;
copyLocalAsset(site.faviconUrl);
});
}
});
}
} catch (e) {
console.error('Error copying faviconUrl assets:', e);
}
// 如果配置了favicon确保文件存在并复制
if (config.site.favicon) {
try {

View File

@@ -261,10 +261,16 @@ window.MeNav = {
const newSite = document.createElement('a');
newSite.className = siteCardStyle ? `site-card site-card-${siteCardStyle}` : 'site-card';
const siteName = data.name || '未命名站点';
const siteUrl = data.url || '#';
const siteIcon = data.icon || 'fas fa-link';
const siteDescription = data.description || (data.url ? menavExtractDomain(data.url) : '');
const siteName = data.name || '未命名站点';
const siteUrl = data.url || '#';
const siteIcon = data.icon || 'fas fa-link';
const siteDescription = data.description || (data.url ? menavExtractDomain(data.url) : '');
const siteFaviconUrl = data && data.faviconUrl ? String(data.faviconUrl).trim() : '';
const siteForceIconModeRaw = data && data.forceIconMode ? String(data.forceIconMode).trim() : '';
const siteForceIconMode =
siteForceIconModeRaw === 'manual' || siteForceIconModeRaw === 'favicon'
? siteForceIconModeRaw
: '';
newSite.href = siteUrl;
newSite.title = siteName + (siteDescription ? ' - ' + siteDescription : '');
@@ -276,9 +282,11 @@ window.MeNav = {
// 设置数据属性
newSite.setAttribute('data-type', 'site');
newSite.setAttribute('data-name', siteName);
newSite.setAttribute('data-url', data.url || '');
newSite.setAttribute('data-icon', siteIcon);
newSite.setAttribute('data-description', siteDescription);
newSite.setAttribute('data-url', data.url || '');
newSite.setAttribute('data-icon', siteIcon);
if (siteFaviconUrl) newSite.setAttribute('data-favicon-url', siteFaviconUrl);
if (siteForceIconMode) newSite.setAttribute('data-force-icon-mode', siteForceIconMode);
newSite.setAttribute('data-description', siteDescription);
// projects repo 风格:与模板中的 repo 结构保持一致(不走 favicon 逻辑)
if (siteCardStyle === 'repo') {
@@ -394,11 +402,46 @@ window.MeNav = {
iconsMode = 'favicon';
}
if (iconsMode === 'favicon' && data.url && /^https?:\/\//i.test(data.url)) {
const faviconUrl = `https://t3.gstatic.com/faviconV2?client=SOCIAL&type=FAVICON&fallback_opts=TYPE,SIZE,URL&url=${encodeURIComponent(data.url)}&size=32`;
const shouldUseCustomFavicon = Boolean(siteFaviconUrl);
const effectiveIconsMode = siteForceIconMode || iconsMode;
const iconContainer = document.createElement('div');
iconContainer.className = 'icon-container';
if (shouldUseCustomFavicon) {
const iconContainer = document.createElement('div');
iconContainer.className = 'icon-container';
const placeholder = document.createElement('i');
placeholder.className = 'fas fa-circle-notch fa-spin icon-placeholder';
placeholder.setAttribute('aria-hidden', 'true');
const fallback = document.createElement('i');
fallback.className = `${siteIcon} icon-fallback`;
fallback.setAttribute('aria-hidden', 'true');
const favicon = document.createElement('img');
favicon.className = 'favicon-icon';
favicon.src = siteFaviconUrl;
favicon.alt = `${siteName} favicon`;
favicon.loading = 'lazy';
favicon.addEventListener('load', () => {
favicon.classList.add('loaded');
placeholder.classList.add('hidden');
});
favicon.addEventListener('error', () => {
favicon.classList.add('error');
placeholder.classList.add('hidden');
fallback.classList.add('visible');
});
iconContainer.appendChild(placeholder);
iconContainer.appendChild(favicon);
iconContainer.appendChild(fallback);
iconWrapper.appendChild(iconContainer);
} else if (effectiveIconsMode === 'favicon' && siteUrl && /^https?:\/\//i.test(siteUrl)) {
const faviconUrlPrimary = `https://t3.gstatic.com/faviconV2?client=SOCIAL&type=FAVICON&fallback_opts=TYPE,SIZE,URL&url=${encodeURIComponent(siteUrl)}&size=32`;
const faviconUrlFallback = `https://t3.gstatic.cn/faviconV2?client=SOCIAL&type=FAVICON&fallback_opts=TYPE,SIZE,URL&url=${encodeURIComponent(siteUrl)}&size=32`;
const iconContainer = document.createElement('div');
iconContainer.className = 'icon-container';
const placeholder = document.createElement('i');
placeholder.className = 'fas fa-circle-notch fa-spin icon-placeholder';
@@ -410,14 +453,21 @@ window.MeNav = {
const favicon = document.createElement('img');
favicon.className = 'favicon-icon';
favicon.src = faviconUrl;
favicon.src = faviconUrlPrimary;
favicon.alt = `${siteName} favicon`;
favicon.loading = 'lazy';
favicon.loading = 'lazy';
let faviconFallbackTried = false;
favicon.addEventListener('load', () => {
favicon.classList.add('loaded');
placeholder.classList.add('hidden');
});
favicon.addEventListener('error', () => {
if (!faviconFallbackTried) {
faviconFallbackTried = true;
favicon.src = faviconUrlFallback;
return;
}
favicon.classList.add('error');
placeholder.classList.add('hidden');
fallback.classList.add('visible');
@@ -425,14 +475,14 @@ window.MeNav = {
iconContainer.appendChild(placeholder);
iconContainer.appendChild(favicon);
iconContainer.appendChild(fallback);
iconWrapper.appendChild(iconContainer);
} else {
const iconEl = document.createElement('i');
iconEl.className = `${siteIcon} site-icon`;
iconEl.setAttribute('aria-hidden', 'true');
iconWrapper.appendChild(iconEl);
}
iconContainer.appendChild(fallback);
iconWrapper.appendChild(iconContainer);
} else {
const iconEl = document.createElement('i');
iconEl.className = `${siteIcon} site-icon`;
iconEl.setAttribute('aria-hidden', 'true');
iconWrapper.appendChild(iconEl);
}
newSite.appendChild(iconWrapper);
newSite.appendChild(contentWrapper);