feat: support query theme bootstrap for embeds

This commit is contained in:
2026-04-02 20:41:18 +08:00
parent 83071a4b76
commit 6e55bc02b7
4 changed files with 87 additions and 9 deletions

View File

@@ -26,10 +26,22 @@ export default function RootLayout({
children: React.ReactNode; children: React.ReactNode;
}>) { }>) {
return ( return (
<html lang="zh" className={`${outfit.variable} ${jetbrains.variable} h-full antialiased dark`} suppressHydrationWarning> <html lang="zh" className={`${outfit.variable} ${jetbrains.variable} h-full antialiased`} suppressHydrationWarning>
<head> <head>
<script dangerouslySetInnerHTML={{ __html: ` <script dangerouslySetInnerHTML={{ __html: `
(function(){ (function(){
var q=null;
try{
var rawTheme=new URLSearchParams(window.location.search).get('theme');
if(rawTheme){
rawTheme=rawTheme.toLowerCase();
if(rawTheme==='light'||rawTheme==='dark'){
q=rawTheme;
}else if(rawTheme==='auto'||rawTheme==='system'){
q=window.matchMedia('(prefers-color-scheme:dark)').matches?'dark':'light';
}
}
}catch(e){}
var parentTheme=null; var parentTheme=null;
try{ try{
if(window.self!==window.top){ if(window.self!==window.top){
@@ -38,7 +50,8 @@ export default function RootLayout({
} }
}catch(e){} }catch(e){}
var t=localStorage.getItem('theme')||'system'; var t=localStorage.getItem('theme')||'system';
var r=parentTheme||(t==='system'?window.matchMedia('(prefers-color-scheme:dark)').matches?'dark':'light':t); var r=q||parentTheme||(t==='system'?window.matchMedia('(prefers-color-scheme:dark)').matches?'dark':'light':t);
document.documentElement.classList.remove('light','dark');
document.documentElement.classList.add(r); document.documentElement.classList.add(r);
document.documentElement.setAttribute('data-theme',r); document.documentElement.setAttribute('data-theme',r);
})(); })();

View File

@@ -1,5 +1,9 @@
import { describe, expect, test } from "bun:test"; import { describe, expect, test } from "bun:test";
import { resolveIncomingThemeMode, resolveStoredTheme } from "./theme-sync"; import {
parseThemeQuery,
resolveIncomingThemeMode,
resolveStoredTheme,
} from "./theme-sync";
describe("theme sync helpers", () => { describe("theme sync helpers", () => {
test("resolves stored analytics theme values", () => { test("resolves stored analytics theme values", () => {
@@ -17,4 +21,14 @@ describe("theme sync helpers", () => {
expect(resolveIncomingThemeMode("system", true)).toBe("dark"); expect(resolveIncomingThemeMode("system", true)).toBe("dark");
expect(resolveIncomingThemeMode("unknown", true)).toBeNull(); expect(resolveIncomingThemeMode("unknown", true)).toBeNull();
}); });
test("parses theme override from query params", () => {
expect(parseThemeQuery("light")).toBe("light");
expect(parseThemeQuery("dark")).toBe("dark");
expect(parseThemeQuery("auto")).toBe("system");
expect(parseThemeQuery("system")).toBe("system");
expect(parseThemeQuery("LIGHT")).toBe("light");
expect(parseThemeQuery("unknown")).toBeNull();
expect(parseThemeQuery(null)).toBeNull();
});
}); });

View File

@@ -1,6 +1,25 @@
import type { Theme } from "./theme"; import type { Theme } from "./theme";
export type ResolvedTheme = "light" | "dark"; export type ResolvedTheme = "light" | "dark";
export type ThemeQueryMode = "light" | "dark" | "system";
export function parseThemeQuery(value: string | null | undefined): ThemeQueryMode | null {
if (!value) {
return null;
}
const normalized = value.toLowerCase();
if (normalized === "light" || normalized === "dark") {
return normalized;
}
if (normalized === "auto" || normalized === "system") {
return "system";
}
return null;
}
export function resolveStoredTheme( export function resolveStoredTheme(
theme: Theme, theme: Theme,

View File

@@ -1,7 +1,12 @@
"use client"; "use client";
import { createContext, useContext, useState, useEffect, startTransition, type ReactNode } from "react"; import { createContext, useContext, useState, useEffect, startTransition, type ReactNode } from "react";
import { resolveIncomingThemeMode, resolveStoredTheme, type ResolvedTheme } from "./theme-sync"; import {
parseThemeQuery,
resolveIncomingThemeMode,
resolveStoredTheme,
type ResolvedTheme,
} from "./theme-sync";
export type Theme = "light" | "dark" | "system"; export type Theme = "light" | "dark" | "system";
@@ -51,6 +56,14 @@ function getParentResolvedTheme(): ResolvedTheme | null {
} }
} }
function getThemeQuery(): Theme | null {
if (typeof window === "undefined") {
return null;
}
return parseThemeQuery(new URLSearchParams(window.location.search).get("theme"));
}
const ThemeContext = createContext<ThemeContextType>({ const ThemeContext = createContext<ThemeContextType>({
theme: "system", theme: "system",
setTheme: () => {}, setTheme: () => {},
@@ -61,9 +74,18 @@ const ThemeContext = createContext<ThemeContextType>({
export function ThemeProvider({ children }: { children: ReactNode }) { export function ThemeProvider({ children }: { children: ReactNode }) {
const [theme, setThemeState] = useState<Theme>("system"); const [theme, setThemeState] = useState<Theme>("system");
const [isEmbedded, setIsEmbedded] = useState(getIsEmbedded); const [isEmbedded, setIsEmbedded] = useState(getIsEmbedded);
const [queryTheme, setQueryTheme] = useState<Theme | null>(getThemeQuery);
const [parentResolved, setParentResolved] = useState<ResolvedTheme | null>(getParentResolvedTheme); const [parentResolved, setParentResolved] = useState<ResolvedTheme | null>(getParentResolvedTheme);
const [resolved, setResolved] = useState<ResolvedTheme>( const [resolved, setResolved] = useState<ResolvedTheme>(
() => getParentResolvedTheme() ?? resolveStoredTheme("system", getPrefersDark()), () => {
const nextQueryTheme = getThemeQuery();
return (
getParentResolvedTheme()
?? (nextQueryTheme ? resolveStoredTheme(nextQueryTheme, getPrefersDark()) : null)
?? resolveStoredTheme("system", getPrefersDark())
);
},
); );
useEffect(() => { useEffect(() => {
@@ -77,10 +99,12 @@ export function ThemeProvider({ children }: { children: ReactNode }) {
useEffect(() => { useEffect(() => {
const nextIsEmbedded = getIsEmbedded(); const nextIsEmbedded = getIsEmbedded();
const nextQueryTheme = getThemeQuery();
const nextParentResolved = getParentResolvedTheme(); const nextParentResolved = getParentResolvedTheme();
startTransition(() => { startTransition(() => {
setIsEmbedded(nextIsEmbedded); setIsEmbedded(nextIsEmbedded);
setQueryTheme(nextQueryTheme);
setParentResolved((current) => current ?? nextParentResolved); setParentResolved((current) => current ?? nextParentResolved);
}); });
}, []); }, []);
@@ -111,6 +135,10 @@ export function ThemeProvider({ children }: { children: ReactNode }) {
useEffect(() => { useEffect(() => {
const mq = window.matchMedia("(prefers-color-scheme: dark)"); const mq = window.matchMedia("(prefers-color-scheme: dark)");
const root = document.documentElement; const root = document.documentElement;
const activeTheme = queryTheme ?? theme;
const queryResolved = queryTheme
? resolveStoredTheme(queryTheme, mq.matches)
: null;
const applyResolved = (nextResolved: ResolvedTheme) => { const applyResolved = (nextResolved: ResolvedTheme) => {
setResolved(nextResolved); setResolved(nextResolved);
@@ -119,19 +147,23 @@ export function ThemeProvider({ children }: { children: ReactNode }) {
root.setAttribute("data-theme", nextResolved); root.setAttribute("data-theme", nextResolved);
}; };
applyResolved(parentResolved ?? resolveStoredTheme(theme, mq.matches)); applyResolved(
parentResolved
?? queryResolved
?? resolveStoredTheme(theme, mq.matches),
);
if (parentResolved !== null || theme !== "system") { if (parentResolved !== null || activeTheme !== "system") {
return; return;
} }
const handleSystemThemeChange = (event: MediaQueryListEvent) => { const handleSystemThemeChange = (event: MediaQueryListEvent) => {
applyResolved(resolveStoredTheme(theme, event.matches)); applyResolved(resolveStoredTheme(activeTheme, event.matches));
}; };
mq.addEventListener("change", handleSystemThemeChange); mq.addEventListener("change", handleSystemThemeChange);
return () => mq.removeEventListener("change", handleSystemThemeChange); return () => mq.removeEventListener("change", handleSystemThemeChange);
}, [parentResolved, theme]); }, [parentResolved, queryTheme, theme]);
const setTheme = (t: Theme) => { const setTheme = (t: Theme) => {
setThemeState(t); setThemeState(t);