feat(ts): 实现 Polaris 可交互首页
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,10 @@
|
||||
import categoriesData from "@/data/categories.json"
|
||||
import type { Category } from "@/types/navigation"
|
||||
|
||||
const categories = categoriesData satisfies Category[]
|
||||
|
||||
export function getCategories(): Category[] {
|
||||
return categories
|
||||
.map((category) => ({ ...category }))
|
||||
.sort((a, b) => a.sort - b.sort)
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import linksData from "@/data/links.json"
|
||||
import type { NavLink } from "@/types/navigation"
|
||||
|
||||
const links = linksData satisfies NavLink[]
|
||||
|
||||
function cloneLink(link: NavLink): NavLink {
|
||||
return { ...link, keywords: [...link.keywords] }
|
||||
}
|
||||
|
||||
export function getLinks(): NavLink[] {
|
||||
return links.map(cloneLink)
|
||||
}
|
||||
|
||||
export function getPinnedLinks(limit = 8): NavLink[] {
|
||||
// 首页顺序必须独立于分类内 sort,否则 Vercel 与 Figma 无法同时满足两处设计。
|
||||
return links
|
||||
.filter((link) => link.pinned)
|
||||
.map(cloneLink)
|
||||
.sort(
|
||||
(a, b) =>
|
||||
(a.pinnedSort ?? Number.MAX_SAFE_INTEGER) -
|
||||
(b.pinnedSort ?? Number.MAX_SAFE_INTEGER),
|
||||
)
|
||||
.slice(0, limit)
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
import { describe, expect, it } from "vitest"
|
||||
|
||||
import { getCategories } from "@/services/categories"
|
||||
import { getLinks, getPinnedLinks } from "@/services/links"
|
||||
import { getSettings, parseSettings } from "@/services/settings"
|
||||
|
||||
describe("静态数据 service", () => {
|
||||
it("按 sort 返回分类并提供新数组", () => {
|
||||
const first = getCategories()
|
||||
const second = getCategories()
|
||||
|
||||
expect(first.map(({ id }) => id)).toEqual([
|
||||
"ai",
|
||||
"dev",
|
||||
"design",
|
||||
"learning",
|
||||
"efficiency",
|
||||
"media",
|
||||
"life",
|
||||
])
|
||||
expect(first).not.toBe(second)
|
||||
})
|
||||
|
||||
it("按 pinnedSort 返回首页 8 个常用链接", () => {
|
||||
expect(getPinnedLinks().map(({ id }) => id)).toEqual([
|
||||
"github",
|
||||
"chatgpt",
|
||||
"gmail",
|
||||
"notion",
|
||||
"figma",
|
||||
"youtube",
|
||||
"vercel",
|
||||
"bilibili",
|
||||
])
|
||||
})
|
||||
|
||||
it("限制常用链接数量且不修改原数据", () => {
|
||||
const allLinks = getLinks()
|
||||
expect(getPinnedLinks(3)).toHaveLength(3)
|
||||
expect(getLinks()).toEqual(allLinks)
|
||||
})
|
||||
|
||||
it("返回设置对象及嵌套集合的独立副本", () => {
|
||||
const first = getSettings()
|
||||
first.search.enabledProviders.pop()
|
||||
|
||||
expect(first).not.toBe(getSettings())
|
||||
expect(getSettings().search.enabledProviders).toHaveLength(4)
|
||||
})
|
||||
|
||||
it.each([
|
||||
["非法主题", { ...getSettings(), theme: "dark" }],
|
||||
[
|
||||
"非法背景值",
|
||||
{
|
||||
...getSettings(),
|
||||
background: { ...getSettings().background, blur: "8" },
|
||||
},
|
||||
],
|
||||
[
|
||||
"非法侧栏值",
|
||||
{
|
||||
...getSettings(),
|
||||
sidebar: { defaultCollapsed: "false" },
|
||||
},
|
||||
],
|
||||
[
|
||||
"非法 Provider",
|
||||
{
|
||||
...getSettings(),
|
||||
search: {
|
||||
...getSettings().search,
|
||||
defaultProvider: "duckduckgo",
|
||||
},
|
||||
},
|
||||
],
|
||||
["缺少站点名", { ...getSettings(), siteName: undefined }],
|
||||
])("拒绝%s", (_label, value) => {
|
||||
expect(() => parseSettings(value)).toThrow("Invalid Polaris settings")
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,79 @@
|
||||
import settingsData from "@/data/settings.json"
|
||||
import { SEARCH_PROVIDERS, type SearchProvider } from "@/types/search"
|
||||
import type { AppSettings } from "@/types/settings"
|
||||
|
||||
function isSearchProvider(value: unknown): value is SearchProvider {
|
||||
return (
|
||||
typeof value === "string" &&
|
||||
SEARCH_PROVIDERS.includes(value as SearchProvider)
|
||||
)
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value)
|
||||
}
|
||||
|
||||
function invalidSettings(): never {
|
||||
throw new TypeError("Invalid Polaris settings")
|
||||
}
|
||||
|
||||
export function parseSettings(value: unknown): AppSettings {
|
||||
if (!isRecord(value)) invalidSettings()
|
||||
|
||||
const search = value.search
|
||||
const background = value.background
|
||||
const sidebar = value.sidebar
|
||||
|
||||
if (
|
||||
typeof value.siteName !== "string" ||
|
||||
value.theme !== "light-glass" ||
|
||||
!isRecord(search) ||
|
||||
!isSearchProvider(search.defaultProvider) ||
|
||||
!Array.isArray(search.enabledProviders) ||
|
||||
!search.enabledProviders.every(isSearchProvider) ||
|
||||
!isRecord(background) ||
|
||||
(background.type !== "gradient" && background.type !== "image") ||
|
||||
(background.url !== null && typeof background.url !== "string") ||
|
||||
typeof background.blur !== "number" ||
|
||||
!Number.isFinite(background.blur) ||
|
||||
typeof background.overlay !== "number" ||
|
||||
!Number.isFinite(background.overlay) ||
|
||||
!isRecord(sidebar) ||
|
||||
typeof sidebar.defaultCollapsed !== "boolean"
|
||||
) {
|
||||
invalidSettings()
|
||||
}
|
||||
|
||||
// JSON 是外部数据边界;在模块加载时完整校验,避免强制断言掩盖配置拼写或类型错误。
|
||||
return {
|
||||
siteName: value.siteName,
|
||||
theme: value.theme,
|
||||
search: {
|
||||
defaultProvider: search.defaultProvider,
|
||||
enabledProviders: [...search.enabledProviders],
|
||||
},
|
||||
background: {
|
||||
type: background.type,
|
||||
url: background.url,
|
||||
blur: background.blur,
|
||||
overlay: background.overlay,
|
||||
},
|
||||
sidebar: {
|
||||
defaultCollapsed: sidebar.defaultCollapsed,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
const settings = parseSettings(settingsData)
|
||||
|
||||
export function getSettings(): AppSettings {
|
||||
return {
|
||||
...settings,
|
||||
search: {
|
||||
...settings.search,
|
||||
enabledProviders: [...settings.search.enabledProviders],
|
||||
},
|
||||
background: { ...settings.background },
|
||||
sidebar: { ...settings.sidebar },
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user