Share language and theme across the sites; prepare the 0.1.0 release (#13)
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -14,13 +14,14 @@ import {
|
||||
useState,
|
||||
} from "react";
|
||||
import type { ReactNode } from "react";
|
||||
import { LANG_KEY, readPref, subscribePref, writePref } from "./site-prefs";
|
||||
import { setActiveStrings, zh } from "../lib/strings";
|
||||
import { en } from "../lib/strings-en";
|
||||
|
||||
export type LangPref = "zh" | "en" | "system";
|
||||
export type Locale = "zh" | "en";
|
||||
|
||||
const STORAGE_KEY = "penguin-docs.lang";
|
||||
const LANG_PREFS = ["zh", "en", "system"] as const;
|
||||
|
||||
interface LocaleContextValue {
|
||||
lang: LangPref;
|
||||
@@ -44,9 +45,7 @@ function resolve(lang: LangPref): Locale {
|
||||
}
|
||||
|
||||
function initialLang(): LangPref {
|
||||
const stored = localStorage.getItem(STORAGE_KEY);
|
||||
if (stored === "zh" || stored === "en" || stored === "system") return stored;
|
||||
return "system";
|
||||
return readPref<LangPref>(LANG_KEY, LANG_PREFS) ?? "system";
|
||||
}
|
||||
|
||||
export function LocaleProvider({ children }: { children: ReactNode }) {
|
||||
@@ -71,10 +70,13 @@ export function LocaleProvider({ children }: { children: ReactNode }) {
|
||||
}, [lang]);
|
||||
|
||||
const setLang = useCallback((next: LangPref) => {
|
||||
localStorage.setItem(STORAGE_KEY, next);
|
||||
writePref(LANG_KEY, next);
|
||||
setLangState(next);
|
||||
}, []);
|
||||
|
||||
// The sibling site (landing <-> docs) writing the shared key in another tab.
|
||||
useEffect(() => subscribePref<LangPref>(LANG_KEY, LANG_PREFS, setLangState), []);
|
||||
|
||||
return (
|
||||
<LocaleContext.Provider value={{ lang, locale, setLang }}>{children}</LocaleContext.Provider>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
/**
|
||||
* Preferences shared by the landing page and the docs site.
|
||||
*
|
||||
* The two are separate SPAs but ship to one origin (penguin.ooo and penguin.ooo/docs/),
|
||||
* so a single localStorage key is all it takes for a language or theme choice to survive
|
||||
* the hop between them. They previously wrote `penguin-landing.*` and `penguin-docs.*`,
|
||||
* which is why picking dark mode on the landing page and clicking through to the docs
|
||||
* dropped you back into light.
|
||||
*
|
||||
* This module is duplicated verbatim in packages/landing (as theme.tsx and locale.tsx
|
||||
* already are — the two sites share no package). The KEYS below must stay identical on
|
||||
* both sides; if they drift, the sync silently stops working with nothing failing.
|
||||
*/
|
||||
|
||||
export const THEME_KEY = "penguin-site.theme";
|
||||
export const LANG_KEY = "penguin-site.lang";
|
||||
|
||||
/** Pre-unification keys, still read once so an existing visitor keeps their choice. */
|
||||
const LEGACY_KEYS: Record<string, readonly string[]> = {
|
||||
[THEME_KEY]: ["penguin-landing.theme", "penguin-docs.theme"],
|
||||
[LANG_KEY]: ["penguin-landing.lang", "penguin-docs.lang"],
|
||||
};
|
||||
|
||||
/**
|
||||
* Reads a stored preference, falling back to the retired per-site keys. Everything is
|
||||
* wrapped: localStorage throws outright in a cookie-blocked or partitioned context, and
|
||||
* a preference is never worth taking the page down for.
|
||||
*/
|
||||
export function readPref<T extends string>(key: string, allowed: readonly T[]): T | null {
|
||||
try {
|
||||
for (const k of [key, ...(LEGACY_KEYS[key] ?? [])]) {
|
||||
const stored = localStorage.getItem(k);
|
||||
if (stored && (allowed as readonly string[]).includes(stored)) return stored as T;
|
||||
}
|
||||
} catch {
|
||||
// Storage unavailable — fall through to the system default.
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function writePref(key: string, value: string): void {
|
||||
try {
|
||||
localStorage.setItem(key, value);
|
||||
} catch {
|
||||
// Non-persistent session: the choice still applies for this page.
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Live sync for the both-tabs-open case. The storage event fires in every *other* tab on
|
||||
* the origin, so switching theme on the landing page updates an already-open docs tab
|
||||
* without a reload. Navigating between the two sites is a full page load and is covered
|
||||
* by the read on mount instead.
|
||||
*/
|
||||
export function subscribePref<T extends string>(
|
||||
key: string,
|
||||
allowed: readonly T[],
|
||||
onChange: (value: T) => void,
|
||||
): () => void {
|
||||
const handler = (e: StorageEvent) => {
|
||||
if (e.key !== key || e.newValue === null) return;
|
||||
if ((allowed as readonly string[]).includes(e.newValue)) onChange(e.newValue as T);
|
||||
};
|
||||
window.addEventListener("storage", handler);
|
||||
return () => window.removeEventListener("storage", handler);
|
||||
}
|
||||
@@ -6,10 +6,11 @@
|
||||
*/
|
||||
import { createContext, useCallback, useContext, useEffect, useState } from "react";
|
||||
import type { ReactNode } from "react";
|
||||
import { THEME_KEY, readPref, subscribePref, writePref } from "./site-prefs";
|
||||
|
||||
export type ThemeMode = "light" | "dark" | "system";
|
||||
|
||||
const MODE_KEY = "penguin-docs.theme";
|
||||
const THEME_MODES = ["light", "dark", "system"] as const;
|
||||
|
||||
interface ThemeContextValue {
|
||||
mode: ThemeMode;
|
||||
@@ -21,9 +22,7 @@ interface ThemeContextValue {
|
||||
const ThemeContext = createContext<ThemeContextValue | null>(null);
|
||||
|
||||
function initialMode(): ThemeMode {
|
||||
const stored = localStorage.getItem(MODE_KEY);
|
||||
if (stored === "light" || stored === "dark" || stored === "system") return stored;
|
||||
return "system";
|
||||
return readPref<ThemeMode>(THEME_KEY, THEME_MODES) ?? "system";
|
||||
}
|
||||
|
||||
function systemDark(): boolean {
|
||||
@@ -50,10 +49,13 @@ export function ThemeProvider({ children }: { children: ReactNode }) {
|
||||
}, [mode]);
|
||||
|
||||
const setMode = useCallback((next: ThemeMode) => {
|
||||
localStorage.setItem(MODE_KEY, next);
|
||||
writePref(THEME_KEY, next);
|
||||
setModeState(next);
|
||||
}, []);
|
||||
|
||||
// The sibling site (landing <-> docs) writing the shared key in another tab.
|
||||
useEffect(() => subscribePref<ThemeMode>(THEME_KEY, THEME_MODES, setModeState), []);
|
||||
|
||||
return <ThemeContext.Provider value={{ mode, dark, setMode }}>{children}</ThemeContext.Provider>;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user