From 5de792f35918202e2308a3a345efd4ccfaf56db5 Mon Sep 17 00:00:00 2001 From: Ajay Ghanwat Date: Wed, 12 Aug 2026 21:53:07 +0530 Subject: [PATCH] feat(config): add shared browser cookie policy --- bun.lock | 8 +- packages/cli/package.json | 2 +- packages/cli/src/build.ts | 14 +++- packages/dev-server/package.json | 2 +- packages/dev-server/src/index.ts | 17 ++++- packages/dev-server/src/serve-entry.ts | 1 + packages/i18n/package.json | 2 +- packages/i18n/src/index.ts | 16 +++- packages/i18n/test/i18n.test.ts | 6 +- packages/styles/README.md | 29 ++++++++ packages/styles/package.json | 2 +- packages/styles/src/config.ts | 5 +- packages/styles/src/index.ts | 5 ++ packages/styles/src/theme.ts | 89 ++++++++++++++++++++++- packages/styles/test/theme-accent.test.ts | 22 ++++++ 15 files changed, 198 insertions(+), 22 deletions(-) diff --git a/bun.lock b/bun.lock index 8266229d..c3cf9e68 100644 --- a/bun.lock +++ b/bun.lock @@ -268,7 +268,7 @@ }, "packages/cli": { "name": "@wrnexus/cli", - "version": "0.8.24", + "version": "0.8.29", "bin": { "wrnexus": "src/index.ts", }, @@ -332,7 +332,7 @@ }, "packages/dev-server": { "name": "@wrnexus/dev-server", - "version": "0.8.23", + "version": "0.8.28", "dependencies": { "@wrnexus/authz": "workspace:*", "@wrnexus/cache": "workspace:*", @@ -392,7 +392,7 @@ }, "packages/i18n": { "name": "@wrnexus/i18n", - "version": "0.8.9", + "version": "0.8.10", "dependencies": { "@wrnexus/core": "workspace:*", "@wrnexus/plugin": "workspace:*", @@ -588,7 +588,7 @@ }, "packages/styles": { "name": "@wrnexus/styles", - "version": "0.8.14", + "version": "0.8.15", "dependencies": { "@wrnexus/core": "workspace:*", "@wrnexus/plugin": "workspace:*", diff --git a/packages/cli/package.json b/packages/cli/package.json index db0a8515..0aea1f29 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -1,6 +1,6 @@ { "name": "@wrnexus/cli", - "version": "0.8.28", + "version": "0.8.29", "type": "module", "main": "src/index.ts", "exports": { diff --git a/packages/cli/src/build.ts b/packages/cli/src/build.ts index 3394f9f6..b1dcecbc 100644 --- a/packages/cli/src/build.ts +++ b/packages/cli/src/build.ts @@ -44,6 +44,7 @@ import { bundleCss, renderStyles, resolveThemeConfig, + resolveBrowserCookieOptions, renderActiveThemeCss, renderThemeCss, renderThemeRuntime, @@ -493,7 +494,7 @@ export async function runBuild(appRoot: string): Promise { console.log(`✓ Controllers: ${controllersPath}`); // 1a) Theme tokens + client switcher (always emitted; built-in light/dark). - const theme = resolveThemeConfig(config.theme); + const theme = resolveThemeConfig(config.theme, config.cookies); const themeCss = renderThemeCss(theme); const themeJs = renderThemeRuntime(theme); writeFileSync(join(distDir, "theme.css"), themeCss, "utf8"); @@ -541,8 +542,17 @@ export async function runBuild(appRoot: string): Promise { // 1a4) i18n: bake locale messages into the manifest (opt-in via app/locales). const localeMessages = loadLocales(join(appDir, "locales")); + const i18nConfig = config.i18n + ? { + ...config.i18n, + cookie: { + ...resolveBrowserCookieOptions(config.cookies, "language"), + ...(config.i18n.cookie ?? {}), + }, + } + : undefined; const i18n = Object.keys(localeMessages).length - ? resolveI18n(localeMessages, config.i18n) + ? resolveI18n(localeMessages, i18nConfig) : undefined; if (i18n) console.log(`✓ i18n: ${i18n.langs.length} locales (default: ${i18n.default})`); diff --git a/packages/dev-server/package.json b/packages/dev-server/package.json index 6e7acc4c..a8ae1499 100644 --- a/packages/dev-server/package.json +++ b/packages/dev-server/package.json @@ -1,6 +1,6 @@ { "name": "@wrnexus/dev-server", - "version": "0.8.27", + "version": "0.8.28", "type": "module", "main": "src/index.ts", "exports": { diff --git a/packages/dev-server/src/index.ts b/packages/dev-server/src/index.ts index b3b8ab7b..34333ad2 100644 --- a/packages/dev-server/src/index.ts +++ b/packages/dev-server/src/index.ts @@ -12,6 +12,8 @@ import type { Middleware, Mode, SecurityConfig, SeoConfig } from "@wrnexus/core" import { buildRouter, type Router } from "@wrnexus/router"; import { resolveThemeConfig, + resolveBrowserCookieOptions, + type BrowserCookiesConfig, type StylesConfig, type ThemeConfig, type MobileConfig, @@ -101,6 +103,8 @@ export interface ServeOptions { security?: SecurityConfig; /** Design-token theme config (merged over the built-in light/dark). */ theme?: ThemeConfig; + /** Shared browser-cookie defaults and preference-specific overrides. */ + cookies?: BrowserCookiesConfig; /** i18n config (default language + supported locales). */ i18n?: I18nConfig; /** Default database connection (driver + url). Enables `getDb()` and dev auto-migrate. */ @@ -355,7 +359,7 @@ export async function startServer(opts: ServeOptions): Promise { // Compile every `.wrn` into ONE cache dir at the project root, instead of a // `.wrnexus/` next to each source file (and inside node_modules UI dirs). setCompileCacheDir(cacheDir); - const theme = resolveThemeConfig(opts.theme); + const theme = resolveThemeConfig(opts.theme, opts.cookies); const uiStylesPath = uiCssPath(); const schemasJs = await schemaRuntime(router); @@ -375,8 +379,17 @@ export async function startServer(opts: ServeOptions): Promise { // i18n is opt-in by the presence of app/locales/*.json. const localeMessages = loadLocales(join(appDir, "locales"), { strict: opts.i18n?.strict }); + const i18nConfig = opts.i18n + ? { + ...opts.i18n, + cookie: { + ...resolveBrowserCookieOptions(opts.cookies, "language"), + ...(opts.i18n.cookie ?? {}), + }, + } + : undefined; const i18n = Object.keys(localeMessages).length - ? resolveI18n(localeMessages, opts.i18n) + ? resolveI18n(localeMessages, i18nConfig) : undefined; // Databases: configure the default (getDb()) + each named one (getDb("")), diff --git a/packages/dev-server/src/serve-entry.ts b/packages/dev-server/src/serve-entry.ts index 3ce2ab93..43f0a3fd 100644 --- a/packages/dev-server/src/serve-entry.ts +++ b/packages/dev-server/src/serve-entry.ts @@ -54,6 +54,7 @@ const server = await startServer({ seo: config.seo, security: config.security, theme: config.theme, + cookies: config.cookies, i18n: config.i18n, db: config.db, databases: config.databases, diff --git a/packages/i18n/package.json b/packages/i18n/package.json index 70001088..a4f9e445 100644 --- a/packages/i18n/package.json +++ b/packages/i18n/package.json @@ -1,6 +1,6 @@ { "name": "@wrnexus/i18n", - "version": "0.8.9", + "version": "0.8.10", "private": true, "type": "module", "main": "./src/index.ts", diff --git a/packages/i18n/src/index.ts b/packages/i18n/src/index.ts index f12c2548..fb6f5cda 100644 --- a/packages/i18n/src/index.ts +++ b/packages/i18n/src/index.ts @@ -30,6 +30,7 @@ export interface LocaleLoadOptions { export interface I18nCookieConfig { name?: string; maxAge?: number; + domain?: string; path?: string; sameSite?: "Strict" | "Lax" | "None"; secure?: boolean; @@ -253,6 +254,7 @@ export function resolveI18n( cookie: { name: config.cookie?.name ?? LANG_COOKIE, maxAge: config.cookie?.maxAge ?? 31_536_000, + domain: config.cookie?.domain ?? "", path: config.cookie?.path ?? "/", sameSite: config.cookie?.sameSite ?? "Lax", secure: config.cookie?.sameSite === "None" ? true : (config.cookie?.secure ?? false), @@ -464,10 +466,16 @@ export const I18N_RUNTIME = String.raw` var current = state(); if (current.langs && current.langs.indexOf(lang) === -1) return false; var cookie = current.cookie || {}; - var secure = cookie.secure || location.protocol === "https:"; - document.cookie = encodeURIComponent(cookie.name || "${LANG_COOKIE}") + "=" + encodeURIComponent(lang) + - ";path=" + (cookie.path || "/") + ";max-age=" + (cookie.maxAge || 31536000) + - ";samesite=" + (cookie.sameSite || "Lax") + (secure ? ";secure" : ""); + var cookieName = cookie.name || "${LANG_COOKIE}"; + if (window.wrnCookies && typeof window.wrnCookies.set === "function") { + window.wrnCookies.set(cookieName, lang, "language", cookie); + } else { + var secure = cookie.secure || location.protocol === "https:"; + var domain = cookie.domain ? ";domain=" + cookie.domain : ""; + document.cookie = encodeURIComponent(cookieName) + "=" + encodeURIComponent(lang) + + ";path=" + (cookie.path || "/") + ";max-age=" + (cookie.maxAge || 31536000) + + ";samesite=" + (cookie.sameSite || "Lax") + domain + (secure ? ";secure" : ""); + } document.documentElement.lang = lang; document.documentElement.dir = (current.directions && current.directions[lang]) || "ltr"; window.dispatchEvent(new CustomEvent("wrnexus:language-change", { detail: { locale: lang } })); diff --git a/packages/i18n/test/i18n.test.ts b/packages/i18n/test/i18n.test.ts index e5de20e5..a6c74f45 100644 --- a/packages/i18n/test/i18n.test.ts +++ b/packages/i18n/test/i18n.test.ts @@ -1,5 +1,5 @@ import { test, expect } from "bun:test"; -import { resolveI18n, makeT, resolveLang, translateHtml } from "../src/index.ts"; +import { I18N_RUNTIME, resolveI18n, makeT, resolveLang, translateHtml } from "../src/index.ts"; const i18n = resolveI18n( { @@ -27,6 +27,10 @@ test("resolveLang: cookie → Accept-Language → default", () => { expect(resolveLang(i18n, "xx", "de")).toBe("en"); // invalid cookie + unsupported header }); +test("language changes use the shared browser cookie helper", () => { + expect(I18N_RUNTIME).toContain('window.wrnCookies.set(cookieName, lang, "language", cookie)'); +}); + test("translateHtml resolves data-t text and t:attr attributes", () => { const t = makeT(i18n, "en"); const out = translateHtml('', t); diff --git a/packages/styles/README.md b/packages/styles/README.md index 9803057a..8ebff221 100644 --- a/packages/styles/README.md +++ b/packages/styles/README.md @@ -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` diff --git a/packages/styles/package.json b/packages/styles/package.json index cfaf7948..a35924ed 100644 --- a/packages/styles/package.json +++ b/packages/styles/package.json @@ -1,6 +1,6 @@ { "name": "@wrnexus/styles", - "version": "0.8.14", + "version": "0.8.15", "type": "module", "main": "src/index.ts", "exports": { diff --git a/packages/styles/src/config.ts b/packages/styles/src/config.ts index 00fa66f1..ea9ab669 100644 --- a/packages/styles/src/config.ts +++ b/packages/styles/src/config.ts @@ -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; diff --git a/packages/styles/src/index.ts b/packages/styles/src/index.ts index 4f041c52..369f83d3 100644 --- a/packages/styles/src/index.ts +++ b/packages/styles/src/index.ts @@ -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"; diff --git a/packages/styles/src/theme.ts b/packages/styles/src/theme.ts index dd4380fc..3c59ecd5 100644 --- a/packages/styles/src/theme.ts +++ b/packages/styles/src/theme.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 = {}; const names = new Set([ @@ -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}})); diff --git a/packages/styles/test/theme-accent.test.ts b/packages/styles/test/theme-accent.test.ts index 2cf1cd3f..eadce88d 100644 --- a/packages/styles/test/theme-accent.test.ts +++ b/packages/styles/test/theme-accent.test.ts @@ -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",