fix(build): 消除构建期远程拉取隐患 + 评论提交反馈 + 友链卡片移动端

构建隐患(本次丢构建的根因)
- footer.html 用 resources.GetRemote 在每个页面渲染时拉 api/links 与 api/feeds,
  构建节点连不上 Cloudflare 时重试撞穿 Hugo partial 30s 超时 → 整站构建失败
- 改为「流水线 curl 快照 + 模板读本地文件」:
  · 新增 scripts/fetch_snapshots.sh,一次拉齐 conf / friend_links / friend_feeds
    (--max-time 30 --retry 2,JSON 校验,失败一律删文件且恒 exit 0)
  · .cnb.yml Hugo 构建 stage 改为调用该脚本
  · footer.html 改读 site.Data.friend_links / site.Data.friend_feeds,缺失渲染空值
  · .gitignore 忽略 /data/friend_links.json 与 /data/friend_feeds.json
- 降级实测:删掉快照后构建仍 EXIT=0,产物 friendLinks 为空数组

评论提交反馈(artalk.js 新增 __atkSubmit 模块)
- 校验:config.beforeSubmit 拦下 内容空 / 昵称空 / 邮箱空 / 邮箱格式错(网址不校验)
- 发送中:editor-submit 常驻 loading,超 7s 补「网络较慢」
- 成功:editor-submitted 按 normal/reply/edit 分文案,is_pending 提示等待审核
- 失败:拦 POST /api/v2/comments,403/429/4xx/5xx/网络异常全部翻译成人话
- 失败时输入内容保留,并清掉 Artalk 自带的重复小字通知
- 无头浏览器实测 19/19 通过(POST 走 mock,未向线上写数据)

友链申请卡片移动端
- max-width:640px 下隐藏 .atk-fl-nick(昵称 + 博主徽章),桌面端不变

改动:6 files, +282 / -61
This commit is contained in:
zqlit committed 2026-10-04 17:38:04 +08:00
1 parent 836743b16d
commit 571d552c9d
6 files changed
+355 -61

No files matched your search

+9 -17
View File
@@ -55,23 +55,15 @@
hugo version
# ── /conf 快照(评论首屏优化)────────────────────────────────────
# 构建期拉一次 Artalk 的 /conf 落到 data/,Hugo 会内联进每个页面。
# 前端据此对匿名 /conf 请求本地合成响应 —— Artalk 内部 conf 与评论列表
# 是严格串行的,省掉这一跳等于首屏少一整个跨境往返。
# 服务端这份 conf 只由 settings 表决定(唯一因人而异的 imgUpload 由前端
# 按登录态放行真实请求),所以匿名访客的快照人人相同。
# 拉取失败一律降级:删文件 → 模板不内联 → 前端自动走原生远程请求。
# 绝不让它拖垮构建(在 if 条件里失败不会触发 set -e)。
mkdir -p data
if curl -fsS --max-time 30 --retry 1 --retry-delay 2 \
"https://api.200181.xyz/api/v2/conf" -o data/artalk_conf.json \
&& python3 -c "import json;json.load(open('data/artalk_conf.json',encoding='utf-8'))"; then
echo "✅ conf 快照就绪 $(wc -c < data/artalk_conf.json | tr -d ' ') 字节"
else
rm -f data/artalk_conf.json
echo "⚠️ conf 快照拉取失败,已降级为运行时远程拉取"
fi
# ── 构建期数据快照(评论 conf / 友链 / 友圈)────────────────────
# ★ 这里必须用「先 curl 落盘、模板再读本地文件」,**不许**模板里
# 用 resources.GetRemote:那是「渲染过程中发网络请求」,footer 每页
# 都要走一遍,构建节点到 Cloudflare 一抖动就会重试到撞穿 Hugo 的
# partial 30s 超时 → 整站构建失败(2026-10-04 实测白等 14.5 分钟)。
# 改成快照后最坏只是数据为空,构建永远不挂。
# 详见 scripts/fetch_snapshots.sh 顶部注释。
# 脚本内部对每个快照都做了「拉失败就删文件」的降级,且恒 exit 0。
bash scripts/fetch_snapshots.sh
python3 scripts/add_draft_to_hidden.py
rm -rf public resources/_gen
+4 -1
View File
@@ -15,5 +15,8 @@ node_modules/
.ci_status
# CNB 密钥仓库的本地草稿(含真实令牌,仅供往 Web 界面粘贴,绝不入库)
cnb-secrets.yml
# 构建期拉取的 Artalk /conf 快照(每次构建现拉,不入库,避免陈旧配置被 git 固化)
# 构建期拉取的数据快照(每次构建现拉,不入库,避免陈旧数据被 git 固化)
# 由 scripts/fetch_snapshots.sh 生成,见 .cnb.yml「Hugo 构建」stage
/data/artalk_conf.json
/data/friend_links.json
/data/friend_feeds.json
+73
View File
@@ -0,0 +1,73 @@
#!/usr/bin/env bash
# =============================================================================
# 构建期数据快照 —— 把「模板渲染时必须用、但只能从远端拿」的数据先 curl 成本地文件
# =============================================================================
# 为什么必须这么做(2026-10-04 实战教训):
# footer.html 原先用 resources.GetRemote 拉 /api/links 与 /api/feeds。Hugo 的
# GetRemote 是**在模板渲染过程中同步发请求**的,而 footer 每个页面都要走一遍。
# 构建节点(国内)到 api.200181.xyz(Cloudflare)一旦抖动:
# WARN Links API fetch failed: dial tcp 104.21.58.236:443: i/o timeout
# WARN Links API fetch failed: ... connection reset by peer
# Artalk 无关,但这两个拉取会一直重试 → 撞穿 Hugo 的 `partial` 30 秒超时 →
# ERROR render of "home" failed: partial "footer.html" timed out after 30s
# Error: error building site → exit code: 1
# 整站构建失败、站点不更新(实测白等 14.5 分钟)。
#
# 改成「stage 里 curl 一次 + 模板只读本地文件」后:
# 网络问题最多让某个快照变旧/为空(模板 with 判空 → 渲染空值),
# **永远不会再让构建挂掉**。命中失败时一律删文件,绝不留半截内容。
#
# 在流水线里由 .cnb.yml 的「Hugo 构建」stage 调用;本地预览想拿到同款数据
# 也可以直接跑:bash scripts/fetch_snapshots.sh
#
# 产物(均在 .gitignore 里,不入库):
# data/artalk_conf.json 评论 /conf(首屏省一跳跨境往返,见 partials/artalk.html)
# data/friend_links.json 友链 → window.friendLinks(评论区「友链」徽章)
# data/friend_feeds.json 友圈 → window.friendFeeds(评论区「友圈」徽章)
# =============================================================================
set -u
BASE="${RSSAPI_BASE:-https://api.200181.xyz}"
BASE="${BASE%/}"
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
DATA_DIR="$ROOT/data"
mkdir -p "$DATA_DIR"
# JSON 合法性校验:python3 不一定有(本机 Windows 可能没有),没有就跳过校验
validate_json() {
command -v python3 >/dev/null 2>&1 || return 0
python3 -c 'import json,sys;json.load(open(sys.argv[1],encoding="utf-8"))' "$1" 2>/dev/null
}
# snap <输出文件名> <URL>
# 成功:原子替换(先写 .tmp 再 mv),并打印字节数
# 失败:删除目标文件(含 .tmp),返回非 0 —— 但本脚本最后统一 exit 0,
# 因为「快照拿不到」是可接受的降级,不该阻断构建。
snap() {
local name="$1" url="$2" out tmp ok=1
out="$DATA_DIR/$name"
tmp="$out.tmp"
if curl -fsS --max-time 30 --retry 2 --retry-delay 2 --retry-connrefused \
-H 'User-Agent: hugo-build-snapshot' "$url" -o "$tmp" 2>/dev/null; then
if [ -s "$tmp" ] && validate_json "$tmp"; then
mv -f "$tmp" "$out"
echo "✅ $name 就绪 $(wc -c < "$out" | tr -d ' ') 字节"
return 0
fi
echo "⚠️ $name 内容校验失败(空文件或非法 JSON)"
else
echo "⚠️ $name 拉取失败:$url"
fi
rm -f "$tmp" "$out"
return 1
}
echo "── 构建期快照(源:$BASE)──"
snap artalk_conf.json "$BASE/api/v2/conf" || true
snap friend_links.json "$BASE/api/links?all=1" || true
snap friend_feeds.json "$BASE/api/feeds" || true
exit 0
+224
View File
@@ -718,6 +718,18 @@ window.initArtalk = function() {
saveInitCount(); // 持久化到 sessionStorage
var config = Object.assign({}, window.artalkConfig); // Clone config
// ── 提交前校验(昵称 / 邮箱必填;网址随意)──────────────────────────
// Artalk 的 editor.submit() 实现是:
// conf.beforeSubmit ? conf.beforeSubmit(editor, next) : trigger('editor-submit')
// 也就是官方就留了拦截点 —— 不调 next() 就不会走提交流程。
// 挂在这份 config 上(而不是内联 <script> 里的 window.artalkConfig)是因为
// 两条 init 路径(带 / 不带 localStorage 缓存)都从它派生,挂这里两个分支
// 必然都带上;何况缓存那份只存简单类型,函数本来就存不下来。
if (window.__atkSubmit && window.__atkSubmit.beforeSubmit) {
config.beforeSubmit = window.__atkSubmit.beforeSubmit;
}
var server = config.server.replace(/\/$/, '');
var confKey = 'artalk_conf_' + btoa(server);
var cachedConf = null;
@@ -759,6 +771,14 @@ window.initArtalk = function() {
var artalk = window.artalk; // Local reference
// ── 评论提交反馈(发送中 / 成功;失败由文件末尾的 fetch 拦截兜底)──
// editor-submit = 提交开始(beforeSubmit 放行后才会触发)
// editor-submitted = 提交成功(Artalk 的失败分支直接 return,不触发本事件)
if (window.__atkSubmit) {
artalk.on('editor-submit', window.__atkSubmit.begin);
artalk.on('editor-submitted', window.__atkSubmit.done);
}
// Sync Theme Mode Immediately
var currentTheme = document.documentElement.getAttribute('data-theme');
if (typeof artalk.setDarkMode === 'function') {
@@ -1455,3 +1475,207 @@ artalk.on('list-loaded', function() {
}
}, true);
})();
// ============================================================================
// 评论提交反馈 —— 校验 / 发送中 / 成功 / 失败
//
// 为什么需要:评论接口(Cloudflare)在国内常年丢包,读者点完「发送」可能好几秒
// 没有任何反馈,根本没法判断到底发出去没有;而服务端对「昵称 / 邮箱缺失」是直接
// 返 400 的,客户端这边却只在编辑框里弹一行小字,很容易被忽略。
//
// 四个挂点:
// 1) config.beforeSubmit → 提交前校验(内容 / 昵称 / 邮箱必填,邮箱格式;
// 网址随意不校验)。不调 next() 就不发请求,
// 省掉一次注定失败的跨境往返。
// 2) artalk.on('editor-submit') → 提交开始:常驻的「评论发送中…」loading toast;
// 超过 7 秒仍未结束再补一条「网络较慢」。
// 3) artalk.on('editor-submitted') → 提交成功(Artalk 失败分支直接 return,不会
// 触发本事件):关掉 loading,按评论 / 回复 /
// 编辑三种模式弹成功 toast。
// 4) 拦 fetch 的 POST /api/v2/comments → 失败(HTTP 错误或网络异常)时,把服务端
// 的 msg / 标记翻译成人话弹 error toast,并删掉
// Artalk 自己那行重复的小字通知。
//
// 注意:Artalk 提交失败时**不会**清空输入框(只有成功才 reset),所以失败提示里
// 可以放心说「内容还在」。另外所有失败都是「服务端已拒」——loading toast 必须
// 在同一轮里收干净,不能留一个转圈的 toast 挂在那儿。
// ============================================================================
(function () {
if (window.__atkSubmit) return; // PJAX 会重复执行本文件,只装一次
var P = (window.__atkSubmit = {});
var POST_RE = /\/api\/v2\/comments(\?|$)/;
var EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
var loading = false; // loading toast 是否挂着
var slowTimer = null; // 慢网补充提示的定时器
var editor = null; // beforeSubmit 拿到的编辑器实例(用来判提交模式)
var result = null; // 本轮 POST 的结果,由下面的 fetch 拦截写入
function toast(type, text) {
try {
if (window.Toast && typeof window.Toast[type] === 'function') return window.Toast[type](text);
if (window.showToast) window.showToast(text, type);
} catch (e) {}
return null;
}
// 一次性收干净:不用维护 Qmsg 实例引用,也顺手清掉「网络较慢」那条。
// (Qmsg 的 loading 是 autoClose:false,不主动关就会一直转)
function closeLoading() {
loading = false;
if (slowTimer) { clearTimeout(slowTimer); slowTimer = null; }
try { if (window.Toast && window.Toast.closeAll) window.Toast.closeAll(); } catch (e) {}
}
// ── 校验用的取值 ─────────────────────────────────────────────────────
function val(el) { return el && typeof el.value === 'string' ? el.value.trim() : ''; }
function headerEls() {
// 首选 Artalk 编辑器自己的访问器:返回的是**这一份编辑器**的输入框。
// ⚠️ 不能拿 document.querySelector('[name=nick]') 顶替:Artalk 的 DOM 里
// 存在不止一个 nick 输入框(隐藏模板、未激活的回复框都算),拿错就会
// 把提示弹在一个看不见的框上,focus 也静默失败。
try {
if (editor && typeof editor.getHeaderInputEls === 'function') {
var h = editor.getHeaderInputEls();
if (h && (h.nick || h.email)) return h;
}
} catch (e) {}
var ed = document.querySelector('.atk-main-editor');
if (!ed) return {};
return {
nick: ed.querySelector('.atk-header [name="nick"]'),
email: ed.querySelector('.atk-header [name="email"]'),
link: ed.querySelector('.atk-header [name="link"]')
};
}
function focusEl(el) {
if (!el) return;
try { el.focus({ preventScroll: true }); } catch (e) { try { el.focus(); } catch (e2) {} }
try { el.scrollIntoView({ block: 'center', behavior: 'smooth' }); } catch (e) {}
}
// ── 1) 提交前校验:返回 false 表示已拦下,不发请求 ─────────────────────
P.beforeSubmit = function (ed, next) {
editor = ed || editor;
var content = '';
try { content = String(ed.getContentFinal() || '').trim(); } catch (e) {}
var els = headerEls();
if (!content) { toast('warning', '评论内容不能为空'); return; }
var nick = val(els.nick), email = val(els.email);
if (!nick) { toast('warning', '请先填写昵称'); focusEl(els.nick); return; }
if (!email) { toast('warning', '请先填写邮箱(有人回复你时才能通知到)'); focusEl(els.email); return; }
if (!EMAIL_RE.test(email)) { toast('warning', '邮箱格式好像不对,检查一下'); focusEl(els.email); return; }
// 网址(link)留空或随便填都行 —— 不校验。服务端也只在非空时才查格式
next();
};
// ── 2) 提交开始 ──────────────────────────────────────────────────────
P.begin = function () {
closeLoading(); // 极端情况下上一轮没收干净
loading = true;
result = null;
toast('loading', '评论发送中…');
// 慢网补充提示:CF 丢包时首包可能要好几秒,别让读者以为按钮坏了
slowTimer = setTimeout(function () {
slowTimer = null;
if (loading) toast('info', '网络较慢,评论仍在提交中…');
}, 7000);
};
// ── 3) 提交成功 ──────────────────────────────────────────────────────
P.done = function () {
var r = result;
closeLoading();
if (r && r.pending) { toast('info', '评论已提交,等待审核'); return; }
var st = '';
try { st = (editor && typeof editor.getState === 'function' && editor.getState()) || ''; } catch (e) {}
if (st === 'edit') { toast('success', '修改已保存'); return; }
if (st === 'reply') { toast('success', '回复发送成功'); return; }
toast('success', '评论发送成功');
};
// ── 4) 失败:把服务端响应翻译成人话 ──────────────────────────────────
// 服务端(blog-admin)的文案见 src/routes/comments.ts:
// 400 'Invalid parameters: name / email / content / page_key are required'
// 400 'Invalid Email' / 400 'Invalid Link'
// 429 'Too many requests, please slow down'
// 403 'Human verification required'(带 need_captcha,被自研人机门禁拦下)
// 403 'Admin access required'(带 need_login,昵称/邮箱撞上管理员账号)
// 5xx 'Comment failed' 等
function humanize(r) {
r = r || {};
var d = r.data || {};
var msg = String(r.msg || '').trim();
if (r.network) return '网络不稳定,评论没发出去 —— 内容还在,稍后重试';
if (d.need_login) return '该昵称或邮箱是管理员账号,请先登录后台再评论';
if (d.need_captcha) return '请先点一下「点击验证」,通过后再发送评论';
if (r.status === 429 || /too many/i.test(msg)) return '评论太频繁了,歇一会儿再发';
if (/invalid email/i.test(msg)) return '邮箱格式不对,检查一下再发';
if (/invalid link/i.test(msg)) return '网址格式不对(也可以留空)';
if (/parent comment not found/i.test(msg)) return '要回复的那条评论已经不在了';
if (/required/i.test(msg)) return '昵称、邮箱和内容都要填哦';
if (r.status >= 500) return '服务器开小差了,评论没发出去,稍后重试';
return msg || ('评论发送失败(HTTP ' + r.status + '),稍后重试');
}
function failToast(r) {
closeLoading();
toast('error', humanize(r));
// Artalk 还会在自己编辑框里塞一行 "评论失败: ..." 的小字通知,与 toast 重复。
// 只删文本匹配的那一条,避免误伤「内容已自动恢复」之类其它通知。
setTimeout(function () {
var ns = document.querySelectorAll('.atk-notify');
for (var i = 0; i < ns.length; i++) {
if (/评论失败|Failed to comment/.test(ns[i].textContent || '')) {
if (ns[i].parentNode) ns[i].parentNode.removeChild(ns[i]);
}
}
}, 80);
}
// ── fetch 拦截:只认「新建评论」这一个请求 ────────────────────────────
// 之所以要先 await 读完 body 再放行,是因为 Artalk 在 `await fetch(...)` 之后
// 才触发 editor-submitted;如果这里用 res.clone().json() 异步解析而不 await,
// 事件的回调会先跑,读到的是上一轮的 result(成功/审核状态就串了)。
var origFetch = window.fetch;
if (typeof origFetch === 'function') {
window.fetch = function (input, init) {
var url = String(typeof input === 'string' ? input : (input && input.url) || '');
var method = String((init && init.method) || (input && input.method) || 'GET').toUpperCase();
if (method !== 'POST' || !POST_RE.test(url)) return origFetch.apply(this, arguments);
var p = origFetch.apply(this, arguments);
return p.then(function (res) {
if (res && res.ok) {
// 成功:读一下 body 看是不是落进了审核队列(服务端返回的就是
// 煮熟的评论对象本体,is_pending 是它的字段)
return res.clone().json().then(function (d) {
result = { ok: true, pending: !!(d && d.is_pending) };
return res;
}).catch(function () {
result = { ok: true, pending: false };
return res;
});
}
// 失败:Artalk 拿到 !res.ok 会自己 throw,这里只负责记因 + 弹 toast
return res.clone().json().then(function (d) {
result = { ok: false, status: res.status, msg: (d && d.msg) || '', data: d || {} };
failToast(result);
return res;
}).catch(function () {
result = { ok: false, status: res.status, msg: 'HTTP ' + res.status, data: {} };
failToast(result);
return res;
});
}, function (err) {
result = { ok: false, network: true, msg: (err && err.message) || 'network error' };
failToast(result);
throw err; // 原样抛回:Artalk 自己的失败处理(保留输入内容)不受影响
});
};
}
})();
+12 -5
View File
@@ -119,7 +119,13 @@
margin: 0 0 6px;
}
/* ---- 移动端:卡片更紧凑(隐藏头像 + 收紧间距 + 微调字号/字距) ---- */
/* ---- 移动端:卡片更紧凑(隐藏头像 + 昵称行 + 收紧间距 + 微调字号) ----
窄屏把「头像 + 昵称(小赵) + 博主徽章」整行去掉:这三个元素在手机宽度下
会把打字机文案挤成一两行、按钮也跟着缩小,观感很差。去掉之后卡片只剩
「说明文案 + 申请友链按钮」,信息密度反而更合理。
昵称与徽章都在 .atk-fl-nick 这一行里(徽章是 artalk.js 克隆后 append 进去的),
所以按 .atk-fl-nick 隐藏即可;徽章再多写一条做兜底(Webkit 老版本对
父级 display:none 的继承没问题,这里只是防御式写法)。 */
@media (max-width: 640px) {
.atk-fl-card {
gap: 8px !important;
@@ -128,14 +134,15 @@
.atk-fl-card .atk-fl-avatar {
display: none !important; /* 窄屏省掉头像,把宽度让给文字 */
}
.atk-fl-card .atk-fl-nick {
font-size: 13.5px !important;
letter-spacing: 0.2px !important;
.atk-fl-card .atk-fl-nick,
.atk-fl-card .atk-fl-nick .atk-badge,
.atk-fl-card .atk-fl-nick .atk-cmt-badge {
display: none !important; /* 昵称(小赵)+ 博主徽章:移动端不显示 */
}
.atk-fl-card .atk-fl-desc {
font-size: 12.5px !important;
letter-spacing: 0.2px !important;
margin-top: 3px !important;
margin-top: 0 !important; /* 昵称行没了,上间距一并去掉 */
}
.atk-fl-card .atk-fl-btn {
font-size: 12.5px !important;
+33 -38
View File
@@ -79,28 +79,29 @@
window.enableInfiniteScroll = {{ .Site.Params.infiniteScroll.enable | default false }};
</script>
{{/* ====== 3. 远程数据获取 - 构建时 ====== */}}
{{/* Fetch friend data at build time — no runtime network requests */}}
{{/* ====== 3. 友链 / 友圈数据 —— 构建期快照,模板**不发网络请求** ====== */}}
{{/* ★ 这里以前是 resources.GetRemote。它是在「每个页面的模板渲染过程中」同步发请求的,
而 footer 每页都要走一遍:构建节点(国内)到 api.200181.xyz(Cloudflare)一抖动,
两个拉取就会重试到撞穿 Hugo 的 partial 30 秒超时 → 整站构建失败、站点不更新
(2026-10-04 实测:WARN Links API fetch failed ... i/o timeout →
ERROR render of "home" failed: partial "footer.html" timed out after 30s)。
现在改为读 data/ 下的本地快照,由流水线 stage 里的 scripts/fetch_snapshots.sh
curl 落盘(见 .cnb.yml「Hugo 构建」)。快照缺失(本地预览 / 拉取失败)时
with 判空 → 渲染空数组,最坏只是评论区少了「友链 / 友圈」徽章,构建立马过。 */}}
{{ $linkHosts := slice }}
{{ $linkNames := slice }}
{{ with resources.GetRemote (printf "%s/api/links?all=1" (site.Params.rssapi.base | default "https://api.200181.xyz")) }}
{{ with .Err }}
{{ warnf "Links API fetch failed: %s" . }}
{{ else }}
{{ with .Content | transform.Unmarshal }}
{{ range .links }}
{{ $host := "" }}
{{ with .url | urls.Parse }}
{{ $host = .Host | strings.TrimPrefix "www." }}
{{ end }}
{{ $title := .name | default "" }}
{{ if $host }}
{{ $linkHosts = $linkHosts | append (dict "host" $host "title" $title) }}
{{ end }}
{{ if $title }}
{{ $linkNames = $linkNames | append (dict "lower" (lower $title) "title" $title) }}
{{ end }}
{{ end }}
{{ with site.Data.friend_links }}
{{ range .links }}
{{ $host := "" }}
{{ with .url | urls.Parse }}
{{ $host = .Host | strings.TrimPrefix "www." }}
{{ end }}
{{ $title := .name | default "" }}
{{ if $host }}
{{ $linkHosts = $linkHosts | append (dict "host" $host "title" $title) }}
{{ end }}
{{ if $title }}
{{ $linkNames = $linkNames | append (dict "lower" (lower $title) "title" $title) }}
{{ end }}
{{ end }}
{{ end }}
@@ -110,24 +111,18 @@
{{ $feedHosts := slice }}
{{ $feedNames := slice }}
{{ with resources.GetRemote (printf "%s/api/feeds" (site.Params.rssapi.base | default "https://api.200181.xyz")) }}
{{ with .Err }}
{{ warnf "Feeds API fetch failed: %s" . }}
{{ else }}
{{ with .Content | transform.Unmarshal }}
{{ range .feeds }}
{{ $host := "" }}
{{ with .url | urls.Parse }}
{{ $host = .Host | strings.TrimPrefix "www." }}
{{ end }}
{{ $title := .feedTitle | default "" }}
{{ if $host }}
{{ $feedHosts = $feedHosts | append (dict "host" $host "title" $title) }}
{{ end }}
{{ if $title }}
{{ $feedNames = $feedNames | append (dict "lower" (lower $title) "title" $title) }}
{{ end }}
{{ end }}
{{ with site.Data.friend_feeds }}
{{ range .feeds }}
{{ $host := "" }}
{{ with .url | urls.Parse }}
{{ $host = .Host | strings.TrimPrefix "www." }}
{{ end }}
{{ $title := .feedTitle | default "" }}
{{ if $host }}
{{ $feedHosts = $feedHosts | append (dict "host" $host "title" $title) }}
{{ end }}
{{ if $title }}
{{ $feedNames = $feedNames | append (dict "lower" (lower $title) "title" $title) }}
{{ end }}
{{ end }}
{{ end }}