/** 站点品牌与页脚:公开设置字段的前端归一化(与 Go service/brand.go 对齐) */ export type FooterLink = { label: string; url: string; new_tab: boolean; /** 所属栏位:left / center / right;缺省 right */ align: FooterLinksAlign; }; /** 页脚链接栏位 */ export type FooterLinksAlign = "left" | "center" | "right"; export const FOOTER_LINKS_ALIGNS: { id: FooterLinksAlign; label: string }[] = [ { id: "left", label: "左" }, { id: "center", label: "中" }, { id: "right", label: "右" }, ]; export function normalizeFooterLinksAlign(raw: unknown): FooterLinksAlign { const s = String(raw ?? "").trim(); if (s === "left" || s === "center" || s === "right") return s; return "right"; } export type BrandSlot = "logo_light" | "logo_dark" | "favicon"; /** 页眉品牌形式 */ export type BrandMark = "image_text" | "image" | "text"; /** 图槽画幅。方标是默认;其余是页眉里常见的横版比例 */ export type BrandLogoSize = "sq" | "2x1" | "3x1" | "4x1" | "16x9"; export const BRAND_LOGO_SIZES: { id: BrandLogoSize; label: string; w: number; h: number }[] = [ { id: "sq", label: "方标", w: 36, h: 36 }, { id: "2x1", label: "2:1", w: 80, h: 40 }, { id: "3x1", label: "3:1", w: 120, h: 40 }, { id: "4x1", label: "4:1", w: 176, h: 44 }, { id: "16x9", label: "16:9", w: 96, h: 54 }, ]; export function normalizeBrandMark(raw: unknown): BrandMark { const s = String(raw ?? "").trim(); if (s === "image" || s === "text" || s === "image_text") return s; return "image_text"; } export function normalizeBrandLogoSize(raw: unknown): BrandLogoSize { const s = String(raw ?? "").trim(); if (s === "sq" || s === "2x1" || s === "3x1" || s === "4x1" || s === "16x9") return s; return "sq"; } export function brandLogoBox(size: BrandLogoSize): { w: number; h: number } { const found = BRAND_LOGO_SIZES.find((item) => item.id === size); return found ? { w: found.w, h: found.h } : { w: 36, h: 36 }; } /** 图槽铺法,与背景图同一套:填充裁切、适应留边、平铺重复、拉伸变形 */ export type BrandLogoFit = "cover" | "contain" | "repeat" | "stretch"; export const BRAND_LOGO_FITS: { id: BrandLogoFit; label: string }[] = [ { id: "cover", label: "填充" }, { id: "contain", label: "适应" }, { id: "repeat", label: "平铺" }, { id: "stretch", label: "拉伸" }, ]; export function normalizeBrandLogoFit(raw: unknown): BrandLogoFit { const s = String(raw ?? "").trim(); if (s === "cover" || s === "contain" || s === "repeat" || s === "stretch") return s; return "contain"; } export const BRAND_LIMITS = { wordmark: 16, slogan: 32, keywordCount: 16, keywordRunes: 20, footerLinks: 8, footerLabel: 32, footerURL: 200, logoBytes: 2 * 1024 * 1024, faviconBytes: 512 * 1024, } as const; const LOGO_URL_RE = /^\/uploads\/brand\/[0-9a-f]{32}\.(png|webp|svg)$/; const FAVICON_URL_RE = /^\/uploads\/brand\/[0-9a-f]{32}\.(png|webp|svg|ico)$/; export function runeCount(s: string): number { return [...s].length; } /** 浏览器默认标题:有标语则为「名称 · 标语」,否则只有名称 */ export function siteDocumentTitle(name: string, slogan: string): string { const n = name.trim() || "姜十三论坛"; const s = slogan.trim(); return s ? `${n} · ${s}` : n; } export function normalizeBrandURL(slot: BrandSlot, raw: unknown): string { const u = String(raw ?? "") .trim() .toLowerCase(); if (!u) return ""; const re = slot === "favicon" ? FAVICON_URL_RE : LOGO_URL_RE; return re.test(u) ? u : ""; } /** 品牌图固定对外地址(与 Go service/brand.go 的 BrandAliasPath* 对齐): * 友链等外部引用此地址,站点换图后地址不变 */ export function brandAliasPath(slot: BrandSlot): string { if (slot === "logo_light") return "/api/brand/logo-light"; if (slot === "logo_dark") return "/api/brand/logo-dark"; return "/api/brand/favicon"; } export function normalizeKeywords(raw: unknown): string[] { if (!Array.isArray(raw)) return []; const seen = new Set(); const out: string[] = []; for (const item of raw) { const parts = String(item ?? "").split(/[,,;;]/); for (const part of parts) { const k = part.trim(); if (!k || runeCount(k) > BRAND_LIMITS.keywordRunes) continue; const key = k.toLowerCase(); if (seen.has(key)) continue; seen.add(key); out.push(k); if (out.length >= BRAND_LIMITS.keywordCount) return out; } } return out; } export function isExternalFooterURL(url: string): boolean { return /^https?:\/\//i.test(url.trim()); } /** 页脚地址:站内单斜杠路径,或 http(s) */ export function footerURLAllowed(raw: string): boolean { const u = raw.trim(); if (!u || runeCount(u) > BRAND_LIMITS.footerURL) return false; if (/\s/.test(u) || u.includes("\\")) return false; if (/^(javascript|data):/i.test(u) || u.startsWith("//")) return false; if (isExternalFooterURL(u)) return true; return u.startsWith("/") && !u.startsWith("//"); } export function normalizeFooterLinks(raw: unknown): FooterLink[] { if (!Array.isArray(raw)) return []; const out: FooterLink[] = []; for (const item of raw) { if (!item || typeof item !== "object") continue; const rec = item as { label?: unknown; url?: unknown; new_tab?: unknown }; const label = String(rec.label ?? "").trim(); const url = String(rec.url ?? "").trim(); if (!label || runeCount(label) > BRAND_LIMITS.footerLabel) continue; if (!footerURLAllowed(url)) continue; const omitted = rec.new_tab === undefined || rec.new_tab === null; const newTab = omitted ? isExternalFooterURL(url) : rec.new_tab !== false; const align = normalizeFooterLinksAlign((rec as { align?: unknown }).align); out.push({ label, url, new_tab: newTab, align }); if (out.length >= BRAND_LIMITS.footerLinks) break; } return out; } export function brandAccept(slot: BrandSlot): string { if (slot === "favicon") return "image/png,image/webp,image/svg+xml,.ico,image/x-icon"; return "image/png,image/webp,image/svg+xml"; } export function brandFileOk(slot: BrandSlot, file: File): string | null { const name = file.name.toLowerCase(); const logo = slot !== "favicon"; const okExt = logo ? /\.(png|webp|svg)$/.test(name) : /\.(png|webp|svg|ico)$/.test(name); if (!okExt) { return logo ? "Logo 仅支持 PNG / WebP / SVG" : "Favicon 仅支持 PNG / WebP / SVG / ICO"; } const max = logo ? BRAND_LIMITS.logoBytes : BRAND_LIMITS.faviconBytes; if (file.size > max) { return logo ? "Logo 不能超过 2MB" : "Favicon 不能超过 512KB"; } return null; }