/** * 桌面端 Header 主导航配置的单一事实源: * 与后端 service.HeaderNavBuiltinKeys 保持一致;Header 渲染、后台管理面板、公共设置 normalize 共用。 * * 配置为有序数组,数组顺序即展示顺序: * - builtin 只存 key,文案/图标/地址/高亮由前端注册表派生(地址随伪静态风格变化); * - custom 存文案 + 地址(站内路径或 http(s) 外链)。 * 缺行/非法输入回落内置默认三项;显式空数组表示全部隐藏(前台不渲染导航)。 * 后续新增内置页面:在本注册表与后端白名单各加一项,后台自动出现可选项。 */ import { footerURLAllowed, isExternalFooterURL, runeCount } from "./brand.ts"; export const HEADER_NAV_BUILTIN_KEYS = ["home", "leaderboard", "library"] as const; export type HeaderNavBuiltinKey = (typeof HEADER_NAV_BUILTIN_KEYS)[number]; /** 内置菜单默认文案;后台面板用同一文案 */ export const HEADER_NAV_BUILTIN_LABELS: Record = { home: "首页", leaderboard: "排行榜", library: "书库", }; export type HeaderNavBuiltinItem = { type: "builtin"; key: HeaderNavBuiltinKey }; export type HeaderNavCustomItem = { type: "custom"; label: string; url: string; new_tab: boolean; }; export type HeaderNavItem = HeaderNavBuiltinItem | HeaderNavCustomItem; export const HEADER_NAV_LIMITS = { items: 8, label: 16, url: 200, } as const; /** 内置默认三项(缺行时回落) */ export const DEFAULT_HEADER_NAV: HeaderNavItem[] = HEADER_NAV_BUILTIN_KEYS.map((key) => ({ type: "builtin" as const, key, })); export function isHeaderNavCustom(item: HeaderNavItem): item is HeaderNavCustomItem { return item.type === "custom"; } /** * 宽容解析服务端配置:未知内置 key/重复内置/非法自定义项跳过; * 入参非数组回落默认;空数组原样返回(全部隐藏)。 */ export function normalizeHeaderNav(raw: unknown): HeaderNavItem[] { if (!Array.isArray(raw)) return DEFAULT_HEADER_NAV.map((item) => ({ ...item })); const out: HeaderNavItem[] = []; const seenBuiltin = new Set(); for (const entry of raw) { if (!entry || typeof entry !== "object") continue; const rec = entry as Record; const type = String(rec.type ?? "").trim(); if (type === "builtin") { const key = String(rec.key ?? "") .trim() .toLowerCase(); if (!(HEADER_NAV_BUILTIN_KEYS as readonly string[]).includes(key) || seenBuiltin.has(key)) { continue; } seenBuiltin.add(key); out.push({ type: "builtin", key: key as HeaderNavBuiltinKey }); } else if (type === "custom") { const label = String(rec.label ?? "").trim(); const url = String(rec.url ?? "").trim(); if (!label || runeCount(label) > HEADER_NAV_LIMITS.label) continue; if (!footerURLAllowed(url)) continue; const omitted = rec.new_tab === undefined || rec.new_tab === null; const newTab = omitted ? isExternalFooterURL(url) : rec.new_tab !== false; out.push({ type: "custom", label, url, new_tab: newTab }); } if (out.length >= HEADER_NAV_LIMITS.items) break; } return out; } /** 自定义菜单是否为外链(外链渲染 target=_blank) */ export function isHeaderNavExternal(url: string): boolean { return isExternalFooterURL(url.trim()); }