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

@@ -300,6 +300,11 @@ categories:
当启用 `icons.mode: favicon`(默认)时,站点卡片会优先显示站点 favicon当 URL 非 http/https、加载失败或网络受限则自动回退到 Font Awesome 图标。相关助手:`ifHttpUrl`(条件)与 `encodeURIComponent`(工具)。
站点级覆盖(可选,写在每个 `sites[]` 节点上):
- `faviconUrl`:为单站点指定图标链接(优先级最高,失败回退到手动图标;本地路径建议以 `assets/` 开头,构建会复制到 `dist/` 同路径)
- `forceIconMode: favicon | manual`:强制该站点使用指定模式(不设置则跟随全局 `icons.mode`
- 优先级:`faviconUrl` > `forceIconMode` > 全局 `icons.mode`
示例(与内置组件实现保持一致):
```handlebars

View File

@@ -4,10 +4,15 @@
data-icon="{{icon}}"
data-level="{{#if level}}{{level}}{{else}}3{{/if}}">
<div class="group-header">
<div class="group-header"{{#ifCond subgroups '||' sites}} data-toggle="group"{{/ifCond}}>
<h{{#if level}}{{add level 1}}{{else}}4{{/if}} data-editable="group-name">
<i class="{{icon}}"></i>
{{name}}
{{#ifCond subgroups '||' sites}}
<span class="toggle-icon">
<i class="fas fa-chevron-down"></i>
</span>
{{/ifCond}}
</h{{#if level}}{{add level 1}}{{else}}4{{/if}}>
</div>

View File

@@ -1,42 +1,83 @@
{{#if url}}
<a href="{{url}}" class="site-card{{#if style}} site-card-{{style}}{{/if}}"
{{#if external}}target="_blank" rel="noopener"{{/if}}
data-type="{{#if type}}{{type}}{{else}}site{{/if}}"
data-name="{{name}}"
data-url="{{url}}"
data-icon="{{#if icon}}{{icon}}{{else}}fas fa-link{{/if}}"
data-description="{{#if description}}{{description}}{{else}}{{extractDomain url}}{{/if}}"
{{#if publishedAt}}data-published-at="{{publishedAt}}"{{/if}}
{{#if source}}data-source="{{source}}"{{/if}}>
<a href="{{url}}" class="site-card{{#if style}} site-card-{{style}}{{/if}}"
{{#if external}}target="_blank" rel="noopener"{{/if}}
data-type="{{#if type}}{{type}}{{else}}site{{/if}}"
data-name="{{name}}"
data-url="{{url}}"
data-icon="{{#if icon}}{{icon}}{{else}}fas fa-link{{/if}}"
{{#if faviconUrl}}data-favicon-url="{{faviconUrl}}"{{/if}}
{{#if forceIconMode}}data-force-icon-mode="{{forceIconMode}}"{{/if}}
data-description="{{#if description}}{{description}}{{else}}{{extractDomain url}}{{/if}}"
{{#if publishedAt}}data-published-at="{{publishedAt}}"{{/if}}
{{#if source}}data-source="{{source}}"{{/if}}>
{{!-- articles首行图标+标题;下方“时间/来源 + 简介”全宽对齐,不被图标列缩进 --}}
{{#ifEquals type "article"}}
<div class="article-card-header">
<div class="site-card-icon" aria-hidden="true">
{{#ifEquals @root.icons.mode "favicon"}}
{{#ifHttpUrl url}}
<div class="icon-container">
<i class="fas fa-circle-notch fa-spin icon-placeholder" aria-hidden="true"></i>
<img
class="favicon-icon"
src="https://t3.gstatic.com/faviconV2?client=SOCIAL&type=FAVICON&fallback_opts=TYPE,SIZE,URL&url={{encodeURIComponent url}}&size=32"
alt="{{name}} favicon"
loading="lazy"
onload="this.classList.add('loaded'); this.previousElementSibling.classList.add('hidden');"
onerror="this.classList.add('error'); this.previousElementSibling.classList.add('hidden'); this.nextElementSibling.classList.add('visible');"
/>
<i class="{{#if icon}}{{icon}}{{else}}fas fa-link{{/if}} icon-fallback" aria-hidden="true"></i>
</div>
{{else}}
<i class="{{#if icon}}{{icon}}{{else}}fas fa-link{{/if}} site-icon" aria-hidden="true"></i>
{{/ifHttpUrl}}
{{else}}
<i class="{{#if icon}}{{icon}}{{else}}fas fa-link{{/if}} site-icon" aria-hidden="true"></i>
{{/ifEquals}}
</div>
<div class="article-card-title">
<h3>{{#if name}}{{name}}{{else}}未命名站点{{/if}}</h3>
</div>
</div>
<div class="article-card-header">
<div class="site-card-icon" aria-hidden="true">
{{!-- 站点图标优先级faviconUrl > forceIconMode > 全局 icons.mode --}}
{{#if faviconUrl}}
<div class="icon-container">
<i class="fas fa-circle-notch fa-spin icon-placeholder" aria-hidden="true"></i>
<img
class="favicon-icon"
src="{{faviconUrl}}"
alt="{{name}} favicon"
loading="lazy"
onload="this.classList.add('loaded'); this.previousElementSibling.classList.add('hidden');"
onerror="this.classList.add('error'); this.previousElementSibling.classList.add('hidden'); this.nextElementSibling.classList.add('visible');"
/>
<i class="{{#if icon}}{{icon}}{{else}}fas fa-link{{/if}} icon-fallback" aria-hidden="true"></i>
</div>
{{else}}
{{#ifEquals forceIconMode "manual"}}
<i class="{{#if icon}}{{icon}}{{else}}fas fa-link{{/if}} site-icon" aria-hidden="true"></i>
{{else}}
{{#ifEquals forceIconMode "favicon"}}
{{#ifHttpUrl url}}
<div class="icon-container">
<i class="fas fa-circle-notch fa-spin icon-placeholder" aria-hidden="true"></i>
<img
class="favicon-icon"
src="https://t3.gstatic.com/faviconV2?client=SOCIAL&type=FAVICON&fallback_opts=TYPE,SIZE,URL&url={{encodeURIComponent url}}&size=32"
alt="{{name}} favicon"
loading="lazy"
onload="this.classList.add('loaded'); this.previousElementSibling.classList.add('hidden');"
onerror="if (!this.dataset.faviconFallbackTried) { this.dataset.faviconFallbackTried = '1'; var next = this.src.replace('t3.gstatic.com', 't3.gstatic.cn'); if (next !== this.src) { this.src = next; return; } } this.classList.add('error'); this.previousElementSibling.classList.add('hidden'); this.nextElementSibling.classList.add('visible');"
/>
<i class="{{#if icon}}{{icon}}{{else}}fas fa-link{{/if}} icon-fallback" aria-hidden="true"></i>
</div>
{{else}}
<i class="{{#if icon}}{{icon}}{{else}}fas fa-link{{/if}} site-icon" aria-hidden="true"></i>
{{/ifHttpUrl}}
{{else}}
{{#ifEquals @root.icons.mode "favicon"}}
{{#ifHttpUrl url}}
<div class="icon-container">
<i class="fas fa-circle-notch fa-spin icon-placeholder" aria-hidden="true"></i>
<img
class="favicon-icon"
src="https://t3.gstatic.com/faviconV2?client=SOCIAL&type=FAVICON&fallback_opts=TYPE,SIZE,URL&url={{encodeURIComponent url}}&size=32"
alt="{{name}} favicon"
loading="lazy"
onload="this.classList.add('loaded'); this.previousElementSibling.classList.add('hidden');"
onerror="if (!this.dataset.faviconFallbackTried) { this.dataset.faviconFallbackTried = '1'; var next = this.src.replace('t3.gstatic.com', 't3.gstatic.cn'); if (next !== this.src) { this.src = next; return; } } this.classList.add('error'); this.previousElementSibling.classList.add('hidden'); this.nextElementSibling.classList.add('visible');"
/>
<i class="{{#if icon}}{{icon}}{{else}}fas fa-link{{/if}} icon-fallback" aria-hidden="true"></i>
</div>
{{else}}
<i class="{{#if icon}}{{icon}}{{else}}fas fa-link{{/if}} site-icon" aria-hidden="true"></i>
{{/ifHttpUrl}}
{{else}}
<i class="{{#if icon}}{{icon}}{{else}}fas fa-link{{/if}} site-icon" aria-hidden="true"></i>
{{/ifEquals}}
{{/ifEquals}}
{{/ifEquals}}
{{/if}}
</div>
<div class="article-card-title">
<h3>{{#if name}}{{name}}{{else}}未命名站点{{/if}}</h3>
</div>
</div>
<div class="article-card-body">
{{#ifCond publishedAt '||' source}}
@@ -89,29 +130,68 @@
{{/if}}
</div>
{{/ifCond}}
{{else}}
<div class="site-card-icon" aria-hidden="true">
{{#ifEquals @root.icons.mode "favicon"}}
{{#ifHttpUrl url}}
<div class="icon-container">
<i class="fas fa-circle-notch fa-spin icon-placeholder" aria-hidden="true"></i>
<img
class="favicon-icon"
src="https://t3.gstatic.com/faviconV2?client=SOCIAL&type=FAVICON&fallback_opts=TYPE,SIZE,URL&url={{encodeURIComponent url}}&size=32"
alt="{{name}} favicon"
loading="lazy"
onload="this.classList.add('loaded'); this.previousElementSibling.classList.add('hidden');"
onerror="this.classList.add('error'); this.previousElementSibling.classList.add('hidden'); this.nextElementSibling.classList.add('visible');"
/>
<i class="{{#if icon}}{{icon}}{{else}}fas fa-link{{/if}} icon-fallback" aria-hidden="true"></i>
</div>
{{else}}
<i class="{{#if icon}}{{icon}}{{else}}fas fa-link{{/if}} site-icon" aria-hidden="true"></i>
{{/ifHttpUrl}}
{{else}}
<i class="{{#if icon}}{{icon}}{{else}}fas fa-link{{/if}} site-icon" aria-hidden="true"></i>
{{/ifEquals}}
</div>
{{else}}
<div class="site-card-icon" aria-hidden="true">
{{!-- 站点图标优先级faviconUrl > forceIconMode > 全局 icons.mode --}}
{{#if faviconUrl}}
<div class="icon-container">
<i class="fas fa-circle-notch fa-spin icon-placeholder" aria-hidden="true"></i>
<img
class="favicon-icon"
src="{{faviconUrl}}"
alt="{{name}} favicon"
loading="lazy"
onload="this.classList.add('loaded'); this.previousElementSibling.classList.add('hidden');"
onerror="this.classList.add('error'); this.previousElementSibling.classList.add('hidden'); this.nextElementSibling.classList.add('visible');"
/>
<i class="{{#if icon}}{{icon}}{{else}}fas fa-link{{/if}} icon-fallback" aria-hidden="true"></i>
</div>
{{else}}
{{#ifEquals forceIconMode "manual"}}
<i class="{{#if icon}}{{icon}}{{else}}fas fa-link{{/if}} site-icon" aria-hidden="true"></i>
{{else}}
{{#ifEquals forceIconMode "favicon"}}
{{#ifHttpUrl url}}
<div class="icon-container">
<i class="fas fa-circle-notch fa-spin icon-placeholder" aria-hidden="true"></i>
<img
class="favicon-icon"
src="https://t3.gstatic.com/faviconV2?client=SOCIAL&type=FAVICON&fallback_opts=TYPE,SIZE,URL&url={{encodeURIComponent url}}&size=32"
alt="{{name}} favicon"
loading="lazy"
onload="this.classList.add('loaded'); this.previousElementSibling.classList.add('hidden');"
onerror="if (!this.dataset.faviconFallbackTried) { this.dataset.faviconFallbackTried = '1'; var next = this.src.replace('t3.gstatic.com', 't3.gstatic.cn'); if (next !== this.src) { this.src = next; return; } } this.classList.add('error'); this.previousElementSibling.classList.add('hidden'); this.nextElementSibling.classList.add('visible');"
/>
<i class="{{#if icon}}{{icon}}{{else}}fas fa-link{{/if}} icon-fallback" aria-hidden="true"></i>
</div>
{{else}}
<i class="{{#if icon}}{{icon}}{{else}}fas fa-link{{/if}} site-icon" aria-hidden="true"></i>
{{/ifHttpUrl}}
{{else}}
{{#ifEquals @root.icons.mode "favicon"}}
{{#ifHttpUrl url}}
<div class="icon-container">
<i class="fas fa-circle-notch fa-spin icon-placeholder" aria-hidden="true"></i>
<img
class="favicon-icon"
src="https://t3.gstatic.com/faviconV2?client=SOCIAL&type=FAVICON&fallback_opts=TYPE,SIZE,URL&url={{encodeURIComponent url}}&size=32"
alt="{{name}} favicon"
loading="lazy"
onload="this.classList.add('loaded'); this.previousElementSibling.classList.add('hidden');"
onerror="if (!this.dataset.faviconFallbackTried) { this.dataset.faviconFallbackTried = '1'; var next = this.src.replace('t3.gstatic.com', 't3.gstatic.cn'); if (next !== this.src) { this.src = next; return; } } this.classList.add('error'); this.previousElementSibling.classList.add('hidden'); this.nextElementSibling.classList.add('visible');"
/>
<i class="{{#if icon}}{{icon}}{{else}}fas fa-link{{/if}} icon-fallback" aria-hidden="true"></i>
</div>
{{else}}
<i class="{{#if icon}}{{icon}}{{else}}fas fa-link{{/if}} site-icon" aria-hidden="true"></i>
{{/ifHttpUrl}}
{{else}}
<i class="{{#if icon}}{{icon}}{{else}}fas fa-link{{/if}} site-icon" aria-hidden="true"></i>
{{/ifEquals}}
{{/ifEquals}}
{{/ifEquals}}
{{/if}}
</div>
<div class="site-card-content">
<h3>{{#if name}}{{name}}{{else}}未命名站点{{/if}}</h3>

View File

@@ -34,8 +34,8 @@
})();
</script>
<link rel="stylesheet" href="style.css">
<link rel="stylesheet" href="https://cdn.bootcdn.net/ajax/libs/font-awesome/6.7.2/css/all.min.css">
</head>
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.7.2/css/all.min.css">
</head>
<body class="loading">
<!-- 滚动进度指示条 -->
<div class="scroll-progress"></div>