feat(config): add shared browser cookie policy
This commit is contained in:
@@ -166,6 +166,35 @@ theme: {
|
||||
|
||||
The client runtime (`renderThemeRuntime`) exposes `window.wrnTheme` with `{ get, set, toggle, bind, themes }`, connects up any `[data-wrn-theme-toggle]` and `[data-wrn-theme-set]` elements on load, and persists the choice to the `wrn-theme` cookie (`max-age` 1 year, `samesite=lax`). `toggle()` cycles through the configured theme names in order.
|
||||
|
||||
### Shared browser cookies
|
||||
|
||||
Set one cookie policy for theme, accent, language, and application cookies:
|
||||
|
||||
```ts
|
||||
export default {
|
||||
cookies: {
|
||||
defaults: {
|
||||
domain: "example.com",
|
||||
path: "/",
|
||||
maxAge: 31_536_000,
|
||||
sameSite: "Lax",
|
||||
secure: true,
|
||||
},
|
||||
language: { maxAge: 2_592_000 },
|
||||
},
|
||||
} satisfies AppConfig;
|
||||
```
|
||||
|
||||
The theme, accent, and language switchers use this policy automatically. Client
|
||||
functions can use the same resolved policy for other values:
|
||||
|
||||
```ts
|
||||
window.wrnCookies.set("dashboard-view", "compact");
|
||||
window.wrnCookies.set("locale-preview", "hi", "language");
|
||||
const value = window.wrnCookies.get("dashboard-view");
|
||||
window.wrnCookies.remove("dashboard-view");
|
||||
```
|
||||
|
||||
## Usage
|
||||
|
||||
### `wrnexus.config.ts`
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@wrnexus/styles",
|
||||
"version": "0.8.14",
|
||||
"version": "0.8.15",
|
||||
"type": "module",
|
||||
"main": "src/index.ts",
|
||||
"exports": {
|
||||
|
||||
@@ -14,7 +14,7 @@ import { pathToFileURL } from "node:url";
|
||||
import type { PerformanceBudgets, SecurityConfig, SeoConfig } from "@wrnexus/core";
|
||||
import type { PluginInput, PluginPermission } from "@wrnexus/plugin";
|
||||
import type { StorageConfig } from "@wrnexus/uploader";
|
||||
import type { ThemeConfig } from "./theme.ts";
|
||||
import type { BrowserCookiesConfig, ThemeConfig } from "./theme.ts";
|
||||
import type { FontConfig } from "./fonts.ts";
|
||||
import { fontCspSources } from "./fonts.ts";
|
||||
import {
|
||||
@@ -299,6 +299,8 @@ export interface AppConfig extends CompatibilityPolicy {
|
||||
fonts?: FontConfig;
|
||||
/** Design-token themes (deep-merged over the built-in light/dark). */
|
||||
theme?: ThemeConfig;
|
||||
/** Shared client cookie policy used by theme, accent, language, and window.wrnCookies. */
|
||||
cookies?: BrowserCookiesConfig;
|
||||
/** i18n: default language + supported locales (strings live in app/locales/*.json). */
|
||||
i18n?: {
|
||||
default?: string;
|
||||
@@ -309,6 +311,7 @@ export interface AppConfig extends CompatibilityPolicy {
|
||||
cookie?: {
|
||||
name?: string;
|
||||
maxAge?: number;
|
||||
domain?: string;
|
||||
path?: string;
|
||||
sameSite?: "Strict" | "Lax" | "None";
|
||||
secure?: boolean;
|
||||
|
||||
@@ -54,6 +54,10 @@ export type {
|
||||
CustomThemePalette,
|
||||
ThemeToken,
|
||||
ThemeSemanticColor,
|
||||
BrowserCookieOptions,
|
||||
BrowserCookiesConfig,
|
||||
BrowserCookiePreference,
|
||||
BrowserCookieApi,
|
||||
} from "./theme.ts";
|
||||
export {
|
||||
DEFAULT_THEMES,
|
||||
@@ -71,6 +75,7 @@ export {
|
||||
renderThemeRuntime,
|
||||
defineThemeTokens,
|
||||
themeVar,
|
||||
resolveBrowserCookieOptions,
|
||||
} from "./theme.ts";
|
||||
|
||||
import type { Mode, StyleProcessContext, StylesConfig } from "./config.ts";
|
||||
|
||||
@@ -111,6 +111,54 @@ export interface ThemeCookieConfig {
|
||||
sameSite?: "Strict" | "Lax" | "None";
|
||||
/** Force or disable Secure. When omitted it follows the current protocol. */
|
||||
secure?: boolean;
|
||||
/** Cookie lifetime in seconds. Defaults to one year. */
|
||||
maxAge?: number;
|
||||
}
|
||||
|
||||
export type BrowserCookieOptions = ThemeCookieConfig;
|
||||
|
||||
/** Shared browser-cookie policy with optional per-preference overrides. */
|
||||
export interface BrowserCookiesConfig {
|
||||
defaults?: BrowserCookieOptions;
|
||||
theme?: BrowserCookieOptions;
|
||||
accent?: BrowserCookieOptions;
|
||||
language?: BrowserCookieOptions;
|
||||
}
|
||||
|
||||
export type BrowserCookiePreference = "theme" | "accent" | "language";
|
||||
|
||||
export interface BrowserCookieApi {
|
||||
readonly config: BrowserCookiesConfig;
|
||||
get(name: string): string | null;
|
||||
options(
|
||||
preference?: BrowserCookiePreference | BrowserCookieOptions,
|
||||
override?: BrowserCookieOptions,
|
||||
): BrowserCookieOptions;
|
||||
set(
|
||||
name: string,
|
||||
value: string,
|
||||
preference?: BrowserCookiePreference | BrowserCookieOptions,
|
||||
override?: BrowserCookieOptions,
|
||||
): void;
|
||||
remove(
|
||||
name: string,
|
||||
preference?: BrowserCookiePreference | BrowserCookieOptions,
|
||||
override?: BrowserCookieOptions,
|
||||
): void;
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
wrnCookies: BrowserCookieApi;
|
||||
}
|
||||
}
|
||||
|
||||
export function resolveBrowserCookieOptions(
|
||||
config: BrowserCookiesConfig | undefined,
|
||||
preference: BrowserCookiePreference,
|
||||
override?: BrowserCookieOptions,
|
||||
): BrowserCookieOptions {
|
||||
return { ...(config?.defaults ?? {}), ...(config?.[preference] ?? {}), ...(override ?? {}) };
|
||||
}
|
||||
|
||||
export interface ThemeConfig {
|
||||
@@ -131,6 +179,8 @@ export interface ResolvedTheme {
|
||||
defaultAccent?: ThemePaletteName;
|
||||
accentNames: ThemePaletteName[];
|
||||
accentCookie?: ThemeCookieConfig;
|
||||
themeCookie?: ThemeCookieConfig;
|
||||
cookies?: BrowserCookiesConfig;
|
||||
}
|
||||
|
||||
/** Cookies used by the SSR renderer and client runtime. */
|
||||
@@ -477,7 +527,10 @@ function themeScheme(tokens: ThemeTokens): ColorScheme {
|
||||
}
|
||||
|
||||
/** Merge the user's theme config over the built-in defaults. */
|
||||
export function resolveThemeConfig(config?: ThemeConfig): ResolvedTheme {
|
||||
export function resolveThemeConfig(
|
||||
config?: ThemeConfig,
|
||||
cookies?: BrowserCookiesConfig,
|
||||
): ResolvedTheme {
|
||||
const palette = resolvePalette(config?.palette);
|
||||
const themes: Record<string, ThemeTokens> = {};
|
||||
const names = new Set<string>([
|
||||
@@ -508,7 +561,9 @@ export function resolveThemeConfig(config?: ThemeConfig): ResolvedTheme {
|
||||
themes,
|
||||
defaultAccent,
|
||||
accentNames,
|
||||
accentCookie: config?.accent?.cookie,
|
||||
themeCookie: resolveBrowserCookieOptions(cookies, "theme"),
|
||||
accentCookie: resolveBrowserCookieOptions(cookies, "accent", config?.accent?.cookie),
|
||||
cookies,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -637,6 +692,8 @@ export function renderThemeRuntime(theme: ResolvedTheme): string {
|
||||
const accentNames = JSON.stringify(theme.accentNames);
|
||||
const defaultAccent = JSON.stringify(theme.defaultAccent ?? null);
|
||||
const cssPrefix = JSON.stringify(THEME_CSS_PREFIX);
|
||||
const cookieConfig = JSON.stringify(theme.cookies ?? {});
|
||||
const themeCookie = JSON.stringify(theme.themeCookie ?? {});
|
||||
const accentCookie = JSON.stringify(theme.accentCookie ?? {});
|
||||
|
||||
return `(function(){
|
||||
@@ -647,6 +704,8 @@ export function renderThemeRuntime(theme: ResolvedTheme): string {
|
||||
var DEFAULT_THEME=${JSON.stringify(theme.default)};
|
||||
var DEFAULT_ACCENT=${defaultAccent};
|
||||
var THEME_CSS_PREFIX=${cssPrefix};
|
||||
var COOKIE_CONFIG=${cookieConfig};
|
||||
var THEME_COOKIE_OPTIONS=${themeCookie};
|
||||
var ACCENT_COOKIE_OPTIONS=${accentCookie};
|
||||
var MAX_AGE=31536000;
|
||||
var el=document.documentElement;
|
||||
@@ -678,14 +737,36 @@ export function renderThemeRuntime(theme: ResolvedTheme): string {
|
||||
}
|
||||
|
||||
function writeCookie(name,value,options){
|
||||
options=options||{};
|
||||
var maxAge=options.maxAge===undefined?MAX_AGE:Math.max(0,Math.floor(options.maxAge));
|
||||
document.cookie=encodeURIComponent(name)+"="+encodeURIComponent(value)+
|
||||
";max-age="+MAX_AGE+cookieOptions(options);
|
||||
";max-age="+maxAge+cookieOptions(options);
|
||||
}
|
||||
|
||||
function deleteCookie(name,options){
|
||||
document.cookie=encodeURIComponent(name)+"=;max-age=0"+cookieOptions(options);
|
||||
}
|
||||
|
||||
function configuredCookieOptions(preference,override){
|
||||
var resolved=Object.assign({},COOKIE_CONFIG.defaults||{});
|
||||
if(typeof preference==="string")Object.assign(resolved,COOKIE_CONFIG[preference]||{});
|
||||
else if(preference)Object.assign(resolved,preference);
|
||||
if(override)Object.assign(resolved,override);
|
||||
return resolved;
|
||||
}
|
||||
|
||||
window.wrnCookies={
|
||||
config:COOKIE_CONFIG,
|
||||
get:readCookie,
|
||||
options:configuredCookieOptions,
|
||||
set:function(name,value,preference,override){
|
||||
writeCookie(name,value,configuredCookieOptions(preference,override));
|
||||
},
|
||||
remove:function(name,preference,override){
|
||||
deleteCookie(name,configuredCookieOptions(preference,override));
|
||||
}
|
||||
};
|
||||
|
||||
function syncStylesheet(themeName,accentName){
|
||||
var link=document.querySelector("link[data-wrnexus-theme]");
|
||||
if(!link)return;
|
||||
@@ -720,7 +801,7 @@ export function renderThemeRuntime(theme: ResolvedTheme): string {
|
||||
function setTheme(name){
|
||||
if(THEMES.indexOf(name)<0)return;
|
||||
el.setAttribute("data-theme",name);
|
||||
writeCookie(THEME_COOKIE,name);
|
||||
writeCookie(THEME_COOKIE,name,THEME_COOKIE_OPTIONS);
|
||||
syncStylesheet(name,getAccent());
|
||||
syncThemeButtons(document);
|
||||
window.dispatchEvent(new CustomEvent("wrnexus:theme-changed",{detail:{theme:name}}));
|
||||
|
||||
@@ -47,6 +47,28 @@ describe("theme accents", () => {
|
||||
expect(runtime).not.toContain("style.setProperty");
|
||||
});
|
||||
|
||||
test("shared cookie config drives theme, accent, and the public browser helper", () => {
|
||||
const theme = resolveThemeConfig(
|
||||
{ palette: "rose" },
|
||||
{
|
||||
defaults: { domain: "example.com", path: "/", maxAge: 600, secure: true },
|
||||
accent: { sameSite: "Strict" },
|
||||
},
|
||||
);
|
||||
const runtime = renderThemeRuntime(theme);
|
||||
|
||||
expect(theme.themeCookie).toEqual({
|
||||
domain: "example.com",
|
||||
path: "/",
|
||||
maxAge: 600,
|
||||
secure: true,
|
||||
});
|
||||
expect(theme.accentCookie?.sameSite).toBe("Strict");
|
||||
expect(runtime).toContain("window.wrnCookies={");
|
||||
expect(runtime).toContain("writeCookie(THEME_COOKIE,name,THEME_COOKIE_OPTIONS)");
|
||||
expect(runtime).toContain("configuredCookieOptions(preference,override)");
|
||||
});
|
||||
|
||||
test("runtime scopes accent cookies to a configured parent domain", () => {
|
||||
const theme = resolveThemeConfig({
|
||||
palette: "rose",
|
||||
|
||||
Reference in New Issue
Block a user