feat: what new (#1172)

This commit is contained in:
ananaBMaster
2026-03-20 19:54:15 -07:00
committed by GitHub
parent 20004edcd4
commit 288cc2c43a
19 changed files with 593 additions and 137 deletions
+5
View File
@@ -0,0 +1,5 @@
---
"@read-frog/extension": patch
---
feat(sidebar): move What's New into a footer popover with blog previews.
Binary file not shown.

After

Width:  |  Height:  |  Size: 696 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.3 KiB

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 429 B

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 801 B

After

Width:  |  Height:  |  Size: 3.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.0 KiB

After

Width:  |  Height:  |  Size: 5.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.1 KiB

After

Width:  |  Height:  |  Size: 13 KiB

-60
View File
@@ -1,60 +0,0 @@
<?xml version="1.0" standalone="no"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg
t="1746996704863"
class="icon"
viewBox="0 0 1219 1024"
version="1.1"
xmlns="http://www.w3.org/2000/svg"
xmlns:xlink="http://www.w3.org/1999/xlink"
width="238.0859375"
height="200"
p-id="3618"
>
<!-- 气泡黑色描边 -->
<path
d="M620.054335 997.952365l-177.169152-162.618664H272.727407A235.333407 235.333407 0 0 1 37.695564 600.264163V253.879625A235.333407 235.333407 0 0 1 272.727407 18.847782h692.769077a235.333407 235.333407 0 0 1 235.031843 235.031843v346.384538a235.333407 235.333407 0 0 1-235.031843 235.031843H784.067734z"
fill="#0F0F0F"
p-id="3619"
/>
<!-- 白色填充(仅位于描边内部) -->
<path
d="M620.054335 997.952365
m-347.289233-904.693539
a160.997754 160.997754 0 0 0 -160.846972 160.620799
v346.384538
a160.997754 160.997754 0 0 0 160.809277 160.809277
h198.957187
l146.522658 134.309295
135.28938 -134.271599
h212.18833
a160.997754 160.997754 0 0 0 160.809277 -160.809277
v-346.422234
a160.997754 160.997754 0 0 0 -160.809277 -160.809277
z"
fill="#FFFFFF"
/>
<!-- 省略号 -->
<path
d="M384.080103 472.438505a37.092435 37.092435 0 0 1-37.130131-37.13013v-49.45658a37.130131 37.130131 0 1 1 74.222566 0v49.45658a37.092435 37.092435 0 0 1-37.092435 37.13013z"
fill="#F15549"
p-id="3620"
/>
<path
d="M631.513786 472.438505a37.092435 37.092435 0 0 1-37.130131-37.13013v-49.45658a37.130131 37.130131 0 1 1 74.222566 0v49.45658a37.092435 37.092435 0 0 1-37.092435 37.13013z"
fill="#F15549"
p-id="3621"
/>
<path
d="M854.181484 472.438505a37.092435 37.092435 0 0 1-37.130131-37.13013v-49.45658a37.130131 37.130131 0 1 1 74.222566 0v49.45658a37.092435 37.092435 0 0 1-37.092435 37.13013z"
fill="#F15549"
p-id="3622"
/>
<!-- 背景装饰圆 -->
<path
d="M433.498988 1.168562a433.498988 433.498988 0 0 1 433.498987 433.498988 438.022455 438.022455 0 0 1-216.447929 375.334732 421.059452 421.059452 0 0 1-217.051058 58.164256 433.498988 433.498988 0 0 1 0-866.997976z"
fill="#FF8D84"
opacity=".19"
p-id="3623"
/>
</svg>

Before

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.8 KiB

After

Width:  |  Height:  |  Size: 62 KiB

@@ -153,7 +153,7 @@ function SelectionPopoverRoot({
setPinned,
triggerElement,
setTriggerElement,
}), [anchor, open, pinned, setOpen, triggerElement])
}), [anchor, open, pinned, setAnchor, setOpen, triggerElement])
return (
<SelectionPopoverRootContext value={contextValue}>
@@ -0,0 +1,248 @@
// @vitest-environment jsdom
import type { ReactNode } from "react"
import { QueryClient, QueryClientProvider } from "@tanstack/react-query"
import { act, fireEvent, render, screen, waitFor } from "@testing-library/react"
import * as React from "react"
import { afterEach, describe, expect, it, vi } from "vitest"
import { WhatsNewFooter } from "../whats-new-footer"
const getLastViewedBlogDateMock = vi.fn()
const getLatestBlogDateMock = vi.fn()
const saveLastViewedBlogDateMock = vi.fn()
vi.mock("#imports", () => ({
i18n: {
t: (key: string) => key,
},
}))
vi.mock("@/components/ui/base-ui/sidebar", () => ({
SidebarMenu: ({ children }: { children: ReactNode }) => <div>{children}</div>,
SidebarMenuButton: ({ children, ...props }: React.ComponentProps<"button">) => (
<button type="button" {...props}>
{children}
</button>
),
SidebarMenuItem: ({ children }: { children: ReactNode }) => <div>{children}</div>,
}))
vi.mock("@/components/ui/base-ui/popover", async () => {
const React = await import("react")
interface PopoverContextValue {
open: boolean
onOpenChange?: (open: boolean) => void
}
const PopoverContext = React.createContext<PopoverContextValue | null>(null)
function usePopoverContext() {
const context = React.use(PopoverContext)
if (!context) {
throw new Error("Popover components must be used within Popover.")
}
return context
}
function Popover({
children,
open = false,
onOpenChange,
}: {
children: ReactNode
open?: boolean
onOpenChange?: (open: boolean) => void
}) {
return (
<PopoverContext value={{ open, onOpenChange }}>
{children}
</PopoverContext>
)
}
function PopoverTrigger({
children,
render,
}: {
children: ReactNode
render?: React.ReactElement<React.ComponentProps<"button">>
}) {
const { open, onOpenChange } = usePopoverContext()
if (render && React.isValidElement(render)) {
const originalOnClick = render.props.onClick
// eslint-disable-next-line react/no-clone-element
return React.cloneElement(render, {
children,
onClick: (event: React.MouseEvent<HTMLButtonElement>) => {
originalOnClick?.(event)
onOpenChange?.(!open)
},
})
}
return (
<button type="button" onClick={() => onOpenChange?.(!open)}>
{children}
</button>
)
}
function PopoverContent({ children }: { children: ReactNode }) {
const { open } = usePopoverContext()
return open ? <div data-testid="whats-new-popover-content">{children}</div> : null
}
return {
Popover,
PopoverContent,
PopoverDescription: ({ children }: { children: ReactNode }) => <div>{children}</div>,
PopoverHeader: ({ children }: { children: ReactNode }) => <div>{children}</div>,
PopoverTitle: ({ children }: { children: ReactNode }) => <div>{children}</div>,
PopoverTrigger,
}
})
vi.mock("@/utils/blog", () => ({
buildBilibiliEmbedUrl: vi.fn(() => null),
getLastViewedBlogDate: (...args: unknown[]) => getLastViewedBlogDateMock(...args),
getLatestBlogDate: (...args: unknown[]) => getLatestBlogDateMock(...args),
hasNewBlogPost: (latestViewedDate: Date | null, latestDate: Date | null) => {
if (!latestDate) {
return false
}
if (!latestViewedDate) {
return true
}
return latestDate > latestViewedDate
},
saveLastViewedBlogDate: (...args: unknown[]) => saveLastViewedBlogDateMock(...args),
}))
function createDeferred<T>() {
let resolve!: (value: T) => void
const promise = new Promise<T>((res) => {
resolve = res
})
return {
promise,
resolve,
}
}
function createQueryClient() {
return new QueryClient({
defaultOptions: {
queries: {
gcTime: 0,
retry: false,
},
},
})
}
function renderWhatsNewFooter() {
const queryClient = createQueryClient()
return {
queryClient,
...render(
<QueryClientProvider client={queryClient}>
<WhatsNewFooter />
</QueryClientProvider>,
),
}
}
const latestBlogPost = {
date: new Date("2026-03-20T12:00:00.000Z"),
description: "Latest updates",
title: "Spring release",
url: "/blog/spring-release",
}
afterEach(() => {
vi.clearAllMocks()
})
describe("whatsNewFooter", () => {
it("auto-opens and marks the post as viewed when unread status arrives after the post", async () => {
const latestBlogPostDeferred = createDeferred<typeof latestBlogPost | null>()
const lastViewedDateDeferred = createDeferred<Date | null>()
getLatestBlogDateMock.mockReturnValueOnce(latestBlogPostDeferred.promise)
getLastViewedBlogDateMock
.mockReturnValueOnce(lastViewedDateDeferred.promise)
.mockResolvedValueOnce(latestBlogPost.date)
saveLastViewedBlogDateMock.mockResolvedValue(undefined)
renderWhatsNewFooter()
await act(async () => {
latestBlogPostDeferred.resolve(latestBlogPost)
})
await screen.findByRole("button", { name: "options.whatsNew.title" })
expect(screen.queryByTestId("whats-new-popover-content")).not.toBeInTheDocument()
expect(saveLastViewedBlogDateMock).not.toHaveBeenCalled()
await act(async () => {
lastViewedDateDeferred.resolve(null)
})
await screen.findByTestId("whats-new-popover-content")
await waitFor(() => {
expect(saveLastViewedBlogDateMock).toHaveBeenCalledTimes(1)
expect(saveLastViewedBlogDateMock).toHaveBeenCalledWith(latestBlogPost.date)
})
})
it("does not auto-open or mark the post as viewed when it is already read", async () => {
getLatestBlogDateMock.mockResolvedValue(latestBlogPost)
getLastViewedBlogDateMock.mockResolvedValue(
new Date("2026-03-21T12:00:00.000Z"),
)
saveLastViewedBlogDateMock.mockResolvedValue(undefined)
renderWhatsNewFooter()
await screen.findByRole("button", { name: "options.whatsNew.title" })
await act(async () => {
await Promise.resolve()
})
expect(screen.queryByTestId("whats-new-popover-content")).not.toBeInTheDocument()
expect(saveLastViewedBlogDateMock).not.toHaveBeenCalled()
})
it("marks the post as viewed after a manual open once the unread query finishes", async () => {
const lastViewedDateDeferred = createDeferred<Date | null>()
getLatestBlogDateMock.mockResolvedValue(latestBlogPost)
getLastViewedBlogDateMock
.mockReturnValueOnce(lastViewedDateDeferred.promise)
.mockResolvedValueOnce(latestBlogPost.date)
saveLastViewedBlogDateMock.mockResolvedValue(undefined)
renderWhatsNewFooter()
const trigger = await screen.findByRole("button", { name: "options.whatsNew.title" })
fireEvent.click(trigger)
expect(await screen.findByTestId("whats-new-popover-content")).toBeInTheDocument()
expect(saveLastViewedBlogDateMock).not.toHaveBeenCalled()
await act(async () => {
lastViewedDateDeferred.resolve(null)
})
await waitFor(() => {
expect(saveLastViewedBlogDateMock).toHaveBeenCalledTimes(1)
expect(saveLastViewedBlogDateMock).toHaveBeenCalledWith(latestBlogPost.date)
})
})
})
+10 -2
View File
@@ -11,6 +11,7 @@ import { Kbd } from "@/components/ui/base-ui/kbd"
import {
Sidebar,
SidebarContent,
SidebarFooter,
SidebarHeader,
} from "@/components/ui/base-ui/sidebar"
import { getCommandPaletteShortcutHint } from "@/utils/os"
@@ -19,6 +20,7 @@ import { commandPaletteOpenAtom } from "../command-palette/atoms"
import { ProductNav } from "./product-nav"
import { SettingsNav } from "./settings-nav"
import { ToolsNav } from "./tools-nav"
import { WhatsNewFooter } from "./whats-new-footer"
export function AppSidebar() {
const setCommandPaletteOpen = useSetAtom(commandPaletteOpenAtom)
@@ -44,9 +46,12 @@ export function AppSidebar() {
className="cursor-pointer"
/>
<InputGroupAddon>
<IconSearch className="size-4 text-muted-foreground" />
<IconSearch className="size-4 text-muted-foreground group-data-[state=collapsed]:-mx-px" />
</InputGroupAddon>
<InputGroupAddon align="inline-end">
<InputGroupAddon
align="inline-end"
className="group-data-[state=collapsed]:hidden"
>
<Kbd>{commandPaletteShortcutHint}</Kbd>
</InputGroupAddon>
</InputGroup>
@@ -56,6 +61,9 @@ export function AppSidebar() {
<ToolsNav />
<ProductNav />
</SidebarContent>
<SidebarFooter className="group-data-[state=expanded]:px-2 transition-all">
<WhatsNewFooter />
</SidebarFooter>
</Sidebar>
)
}
@@ -10,11 +10,8 @@ import {
SidebarMenuItem,
useSidebar,
} from "@/components/ui/base-ui/sidebar"
import { getLastViewedBlogDate, getLatestBlogDate, hasNewBlogPost, saveLastViewedBlogDate } from "@/utils/blog"
import { WEBSITE_URL } from "@/utils/constants/url"
import { cn } from "@/utils/styles/utils"
import { getLastViewedSurvey, hasNewSurvey, saveLastViewedSurvey } from "@/utils/survey"
import { version } from "../../../../package.json"
import { AnimatedIndicator } from "./animated-indicator"
const SURVEY_URL = "https://tally.so/r/kdNN5R"
@@ -23,60 +20,23 @@ export function ProductNav() {
const { open } = useSidebar()
const queryClient = useQueryClient()
const { data: lastViewedDate } = useQuery({
queryKey: ["last-viewed-blog-date"],
queryFn: getLastViewedBlogDate,
})
const { data: latestBlogPost } = useQuery({
queryKey: ["latest-blog-post"],
queryFn: () => getLatestBlogDate(`${WEBSITE_URL}/api/blog/latest`, "en", version),
})
const { data: lastViewedSurveyUrl } = useQuery({
queryKey: ["last-viewed-survey"],
queryFn: getLastViewedSurvey,
})
const handleWhatsNewClick = async () => {
if (latestBlogPost) {
await saveLastViewedBlogDate(latestBlogPost.date)
await queryClient.invalidateQueries({ queryKey: ["last-viewed-blog-date"] })
}
}
const handleSurveyClick = async () => {
await saveLastViewedSurvey(SURVEY_URL)
await queryClient.invalidateQueries({ queryKey: ["last-viewed-survey"] })
}
const showBlogIndicator = hasNewBlogPost(
lastViewedDate ?? null,
latestBlogPost?.date ?? null,
)
const showSurveyIndicator = hasNewSurvey(lastViewedSurveyUrl ?? null, SURVEY_URL)
const blogUrl = latestBlogPost?.url
? `${WEBSITE_URL}${latestBlogPost.url}`
: `${WEBSITE_URL}/blog?latest-indicator=true`
return (
<SidebarGroup>
<SidebarGroupLabel>{i18n.t("options.sidebar.product")}</SidebarGroupLabel>
<SidebarGroupContent>
<SidebarMenu>
<SidebarMenuItem className="relative">
<SidebarMenuButton
render={<a href={blogUrl} target="_blank" rel="noopener noreferrer" onClick={handleWhatsNewClick} />}
className={cn(showBlogIndicator && "text-primary font-semibold hover:text-primary active:text-primary")}
>
<Icon icon="tabler:sparkles" />
<span>{i18n.t("options.whatsNew.title")}</span>
</SidebarMenuButton>
<AnimatedIndicator show={showBlogIndicator && open} />
</SidebarMenuItem>
<SidebarMenuItem className="relative">
<SidebarMenuButton
render={<a href={SURVEY_URL} target="_blank" rel="noopener noreferrer" onClick={handleSurveyClick} />}
@@ -0,0 +1,168 @@
import { i18n } from "#imports"
import { Icon } from "@iconify/react"
import { useQuery, useQueryClient } from "@tanstack/react-query"
import { useCallback, useEffect, useEffectEvent, useState } from "react"
import {
Popover,
PopoverContent,
PopoverDescription,
PopoverHeader,
PopoverTitle,
PopoverTrigger,
} from "@/components/ui/base-ui/popover"
import {
SidebarMenu,
SidebarMenuButton,
SidebarMenuItem,
} from "@/components/ui/base-ui/sidebar"
import {
buildBilibiliEmbedUrl,
getLastViewedBlogDate,
getLatestBlogDate,
hasNewBlogPost,
saveLastViewedBlogDate,
} from "@/utils/blog"
import { WEBSITE_URL } from "@/utils/constants/url"
import { version } from "../../../../package.json"
export function WhatsNewFooter() {
const queryClient = useQueryClient()
const [open, setOpen] = useState(false)
const { data: lastViewedDate, isFetched: isLastViewedDateFetched } = useQuery({
queryKey: ["last-viewed-blog-date"],
queryFn: getLastViewedBlogDate,
})
const { data: latestBlogPost, isFetched: isLatestBlogPostFetched } = useQuery({
queryKey: ["latest-blog-post"],
queryFn: () => getLatestBlogDate(`${WEBSITE_URL}/api/blog/latest`, "en", version),
})
const markLatestBlogPostViewed = useEffectEvent(async () => {
if (!latestBlogPost) {
return
}
if (!isLastViewedDateFetched) {
return
}
if (lastViewedDate && lastViewedDate.getTime() >= latestBlogPost.date.getTime()) {
return
}
await saveLastViewedBlogDate(latestBlogPost.date)
await queryClient.invalidateQueries({ queryKey: ["last-viewed-blog-date"] })
})
const handleOpenChange = useCallback((nextOpen: boolean) => {
setOpen(nextOpen)
}, [])
const openPopover = useEffectEvent(() => {
// eslint-disable-next-line react-hooks-extra/no-direct-set-state-in-use-effect
setOpen(true)
})
const latestBlogPostDate = latestBlogPost?.date ?? null
const latestBlogPostKey = latestBlogPost
? `${latestBlogPost.url}:${latestBlogPost.date.toISOString()}`
: null
const lastViewedDateTimestamp = lastViewedDate?.getTime() ?? null
const shouldAutoOpenPopover = isLastViewedDateFetched
&& isLatestBlogPostFetched
&& hasNewBlogPost(lastViewedDate ?? null, latestBlogPostDate)
useEffect(() => {
if (!shouldAutoOpenPopover) {
return
}
openPopover()
}, [shouldAutoOpenPopover])
// Persist the visible post so it doesn't reopen on the next visit.
useEffect(() => {
if (!open) {
return
}
void markLatestBlogPostViewed()
}, [isLastViewedDateFetched, lastViewedDateTimestamp, latestBlogPostKey, open])
if (!latestBlogPost) {
return null
}
const blogUrl = new URL(latestBlogPost.url, WEBSITE_URL).toString()
const embedUrl = latestBlogPost.videoUrl ? buildBilibiliEmbedUrl(latestBlogPost.videoUrl) : null
return (
<Popover
key={latestBlogPostKey}
open={open}
onOpenChange={handleOpenChange}
>
<SidebarMenu>
<SidebarMenuItem>
<PopoverTrigger
render={(
<SidebarMenuButton
aria-label={i18n.t("options.whatsNew.title")}
/>
)}
>
<Icon icon="tabler:rss" />
<span>{i18n.t("options.whatsNew.title")}</span>
</PopoverTrigger>
</SidebarMenuItem>
</SidebarMenu>
<PopoverContent
align="end"
initialFocus={openType => openType === "keyboard"}
side="top"
sideOffset={8}
className="w-[min(24rem,calc(100vw-2rem))] gap-4 p-3"
>
{embedUrl && (
<div className="overflow-hidden rounded-md border bg-black">
<iframe
title={latestBlogPost.title}
src={embedUrl}
className="aspect-video w-full"
allow="autoplay; fullscreen; picture-in-picture"
allowFullScreen
loading="eager"
referrerPolicy="strict-origin-when-cross-origin"
/>
</div>
)}
<PopoverHeader className="gap-2">
<PopoverTitle>
<a
href={blogUrl}
target="_blank"
rel="noopener noreferrer"
className="group hover:underline"
>
<span className="min-w-0">
{latestBlogPost.title}
<Icon
aria-hidden="true"
icon="tabler:external-link"
className="ml-1 inline size-[1em] align-[-0.125em] text-muted-foreground transition-colors group-hover:text-foreground"
/>
</span>
</a>
</PopoverTitle>
<PopoverDescription>
{latestBlogPost.description}
</PopoverDescription>
</PopoverHeader>
</PopoverContent>
</Popover>
)
}
+30 -1
View File
@@ -1,7 +1,7 @@
import { semanticVersionSchema } from "@read-frog/definitions"
import { describe, expect, it } from "vitest"
import { z } from "zod"
import { hasNewBlogPost } from "../blog"
import { buildBilibiliEmbedUrl, extractBilibiliVideoId, hasNewBlogPost } from "../blog"
describe("hasNewBlogPost", () => {
const baseDate = new Date("2025-01-01")
@@ -147,3 +147,32 @@ describe("semanticVersionSchema", () => {
})
})
})
describe("bilibili helpers", () => {
const videoUrl = "https://www.bilibili.com/video/BV1JoAszwEfF/?vd_source=2c109749b890f71dc77161d6ddf1d5ce"
it("extracts the BVID from a bilibili watch URL", () => {
expect(extractBilibiliVideoId(videoUrl)).toBe("BV1JoAszwEfF")
})
it("builds an autoplay embed URL from a bilibili watch URL", () => {
const embedUrl = buildBilibiliEmbedUrl(videoUrl)
expect(embedUrl).not.toBeNull()
const parsedUrl = new URL(embedUrl!)
expect(parsedUrl.origin).toBe("https://player.bilibili.com")
expect(parsedUrl.pathname).toBe("/player.html")
expect(parsedUrl.searchParams.get("bvid")).toBe("BV1JoAszwEfF")
expect(parsedUrl.searchParams.get("autoplay")).toBe("1")
expect(parsedUrl.searchParams.get("muted")).toBe("1")
})
it("returns null when the URL does not contain a bilibili video id", () => {
expect(buildBilibiliEmbedUrl("https://readfrog.app/blog")).toBeNull()
})
it("returns null when the URL is not hosted on bilibili", () => {
expect(extractBilibiliVideoId("https://readfrog.app/blog?bvid=BV1JoAszwEfF")).toBeNull()
})
})
@@ -0,0 +1,49 @@
import type { PartialDeep } from "type-fest"
import type { ProviderConfig } from "@/types/config/provider"
import { describe, expect, it } from "vitest"
import { DEFAULT_PROVIDER_CONFIG } from "@/utils/constants/providers"
import { updateLLMProviderConfig, updateProviderConfig } from "../provider"
type OpenAIProviderConfig = Extract<ProviderConfig, { provider: "openai" }>
describe("provider config updates", () => {
it("merges nested LLM model updates without changing untouched fields", () => {
const result = updateLLMProviderConfig(DEFAULT_PROVIDER_CONFIG.openai, {
model: {
customModel: "gpt-5-custom",
isCustomModel: true,
},
})
expect(result.model).toEqual({
...DEFAULT_PROVIDER_CONFIG.openai.model,
customModel: "gpt-5-custom",
isCustomModel: true,
})
expect(result.provider).toBe("openai")
})
it("merges provider option objects and preserves the rest of the config", () => {
const result = updateProviderConfig(DEFAULT_PROVIDER_CONFIG.openai, {
connectionOptions: {
timeoutMs: 5000,
},
providerOptions: {
reasoningEffort: "minimal",
},
}) as OpenAIProviderConfig
expect(result.connectionOptions).toEqual({ timeoutMs: 5000 })
expect(result.providerOptions).toEqual({ reasoningEffort: "minimal" })
expect(result.model).toEqual(DEFAULT_PROVIDER_CONFIG.openai.model)
expect(result.provider).toBe("openai")
})
it("rejects merged configs that no longer match the provider schema", () => {
const invalidUpdates = {
provider: "openai",
} as PartialDeep<ProviderConfig>
expect(() => updateProviderConfig(DEFAULT_PROVIDER_CONFIG["google-translate"], invalidUpdates)).toThrow()
})
})
+6 -4
View File
@@ -37,12 +37,15 @@ export const providerConfigAtom = atomFamily((id: string) =>
),
)
function mergeUnknown(base: unknown, updates: unknown): unknown {
return (deepmerge as (base: unknown, updates: unknown) => unknown)(base, updates)
}
export function updateLLMProviderConfig(
config: LLMProviderConfig,
updates: PartialDeep<LLMProviderConfig>,
): LLMProviderConfig {
// @ts-expect-error - Type instantiation too deep due to complex provider union types
const result = deepmerge(config, updates)
const result = mergeUnknown(config, updates) as LLMProviderConfig
return llmProviderConfigItemSchema.parse(result)
}
@@ -50,7 +53,6 @@ export function updateProviderConfig(
config: ProviderConfig,
updates: PartialDeep<ProviderConfig>,
): ProviderConfig {
// Keep the merge input shallow enough for TypeScript, then validate the result with Zod.
const result = deepmerge(config, updates as Partial<ProviderConfig>) as ProviderConfig
const result = mergeUnknown(config, updates) as ProviderConfig
return providerConfigItemSchema.parse(result)
}
+76 -29
View File
@@ -6,17 +6,51 @@ import { sendMessage } from "./message"
const LAST_VIEWED_BLOG_DATE_KEY = "lastViewedBlogDate"
const ONE_DAY_MS = 24 * 60 * 60 * 1000
const BILIBILI_EMBED_HOSTNAME = "player.bilibili.com"
const BILIBILI_HOSTNAME_PATTERN = /(?:^|\.)bilibili\.com$/i
const BILIBILI_VIDEO_ID_PATTERN = /^BV[0-9A-Z]+$/i
/**
* Zod schema for validating blog API response
*/
const blogApiResponseSchema = z.object({
date: z.string(),
function getBilibiliVideoIdFromParsedUrl(parsedUrl: URL): string | null {
if (!BILIBILI_HOSTNAME_PATTERN.test(parsedUrl.hostname)) {
return null
}
const bvid = parsedUrl.searchParams.get("bvid")
if (BILIBILI_VIDEO_ID_PATTERN.test(bvid ?? "")) {
return bvid
}
const [, maybeVideoSegment, maybeVideoId] = parsedUrl.pathname.split("/")
if (maybeVideoSegment !== "video") {
return null
}
return BILIBILI_VIDEO_ID_PATTERN.test(maybeVideoId ?? "") ? maybeVideoId ?? null : null
}
const bilibiliVideoUrlSchema = z.url().superRefine((url, ctx) => {
if (getBilibiliVideoIdFromParsedUrl(new URL(url))) {
return
}
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "Must be a bilibili video URL with a valid BVID",
})
})
const latestBlogPostSchema = z.object({
date: z.string().pipe(z.coerce.date()),
title: z.string(),
description: z.string(),
url: z.string(),
extensionVersion: semanticVersionSchema.nullable().optional(),
}).nullable()
videoUrl: bilibiliVideoUrlSchema.optional(),
extensionVersion: semanticVersionSchema.optional(),
})
export type LatestBlogPost = z.output<typeof latestBlogPostSchema>
const blogApiResponseSchema = latestBlogPostSchema.nullable()
/**
* Saves the last viewed blog date to Chrome storage
@@ -50,6 +84,29 @@ export function hasNewBlogPost(
return latestDate > latestViewedDate
}
export function extractBilibiliVideoId(url: string): string | null {
const result = bilibiliVideoUrlSchema.safeParse(url)
if (!result.success) {
return null
}
return getBilibiliVideoIdFromParsedUrl(new URL(result.data))
}
export function buildBilibiliEmbedUrl(url: string): string | null {
const videoId = extractBilibiliVideoId(url)
if (!videoId) {
return null
}
const embedUrl = new URL(`https://${BILIBILI_EMBED_HOSTNAME}/player.html`)
embedUrl.searchParams.set("bvid", videoId)
embedUrl.searchParams.set("autoplay", "1")
embedUrl.searchParams.set("muted", "1")
embedUrl.searchParams.set("danmaku", "0")
return embedUrl.toString()
}
/**
* Fetches the latest blog post from the Read Frog blog API.
* Uses background fetch with optional 1-day cache.
@@ -58,12 +115,20 @@ export function hasNewBlogPost(
* @param locale - The locale to fetch the latest post for (default: 'en')
* @param extensionVersion - The current extension version to filter compatible posts
* @param useCache - Whether to use cache (default: true)
* @returns Promise resolving to the latest blog post data (date, url, and extensionVersion), or null if no posts found
* @returns Promise resolving to the latest blog post metadata, or null if no posts found
*
* @example
* ```ts
* const latestPost = await getLatestBlogDate('http://localhost:8888/api/blog/latest', 'en', '1.10.0')
* console.log(latestPost) // { date: Date, url: '/blog/post-slug', extensionVersion: '1.11.0' }
* console.log(latestPost)
* // {
* // date: Date,
* // title: 'Spring update',
* // description: 'New subtitle features shipped.',
* // url: '/blog/post-slug',
* // videoUrl: 'https://www.bilibili.com/video/BV...',
* // extensionVersion: '1.11.0',
* // }
*
* // Without cache
* const freshPost = await getLatestBlogDate('http://localhost:8888/api/blog/latest', 'en', '1.10.0', false)
@@ -74,7 +139,7 @@ export async function getLatestBlogDate(
locale: string = "en",
extensionVersion?: string,
useCache: boolean = true,
): Promise<{ date: Date, url: string, extensionVersion?: string | null } | null> {
): Promise<LatestBlogPost | null> {
try {
const url = new URL(apiUrl)
url.searchParams.set("locale", locale)
@@ -98,25 +163,7 @@ export async function getLatestBlogDate(
throw new Error(`Failed to fetch blog: ${response.status}`)
}
// Parse and validate response with Zod
const parsedBody = JSON.parse(response.body)
const validationResult = blogApiResponseSchema.safeParse(parsedBody)
if (!validationResult.success) {
throw new Error(`Invalid blog API response: ${validationResult.error.message}`)
}
const latestPost = validationResult.data
if (!latestPost) {
return null
}
return {
date: new Date(latestPost.date),
url: latestPost.url,
extensionVersion: latestPost.extensionVersion ?? null,
}
return blogApiResponseSchema.parse(JSON.parse(response.body))
}
catch (error) {
logger.error("Error fetching latest blog post:", error)