feat(config): add shared browser cookie policy
This commit is contained in:
@@ -268,7 +268,7 @@
|
|||||||
},
|
},
|
||||||
"packages/cli": {
|
"packages/cli": {
|
||||||
"name": "@wrnexus/cli",
|
"name": "@wrnexus/cli",
|
||||||
"version": "0.8.24",
|
"version": "0.8.29",
|
||||||
"bin": {
|
"bin": {
|
||||||
"wrnexus": "src/index.ts",
|
"wrnexus": "src/index.ts",
|
||||||
},
|
},
|
||||||
@@ -332,7 +332,7 @@
|
|||||||
},
|
},
|
||||||
"packages/dev-server": {
|
"packages/dev-server": {
|
||||||
"name": "@wrnexus/dev-server",
|
"name": "@wrnexus/dev-server",
|
||||||
"version": "0.8.23",
|
"version": "0.8.28",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@wrnexus/authz": "workspace:*",
|
"@wrnexus/authz": "workspace:*",
|
||||||
"@wrnexus/cache": "workspace:*",
|
"@wrnexus/cache": "workspace:*",
|
||||||
@@ -392,7 +392,7 @@
|
|||||||
},
|
},
|
||||||
"packages/i18n": {
|
"packages/i18n": {
|
||||||
"name": "@wrnexus/i18n",
|
"name": "@wrnexus/i18n",
|
||||||
"version": "0.8.9",
|
"version": "0.8.10",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@wrnexus/core": "workspace:*",
|
"@wrnexus/core": "workspace:*",
|
||||||
"@wrnexus/plugin": "workspace:*",
|
"@wrnexus/plugin": "workspace:*",
|
||||||
@@ -588,7 +588,7 @@
|
|||||||
},
|
},
|
||||||
"packages/styles": {
|
"packages/styles": {
|
||||||
"name": "@wrnexus/styles",
|
"name": "@wrnexus/styles",
|
||||||
"version": "0.8.14",
|
"version": "0.8.15",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@wrnexus/core": "workspace:*",
|
"@wrnexus/core": "workspace:*",
|
||||||
"@wrnexus/plugin": "workspace:*",
|
"@wrnexus/plugin": "workspace:*",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@wrnexus/cli",
|
"name": "@wrnexus/cli",
|
||||||
"version": "0.8.28",
|
"version": "0.8.29",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "src/index.ts",
|
"main": "src/index.ts",
|
||||||
"exports": {
|
"exports": {
|
||||||
|
|||||||
@@ -44,6 +44,7 @@ import {
|
|||||||
bundleCss,
|
bundleCss,
|
||||||
renderStyles,
|
renderStyles,
|
||||||
resolveThemeConfig,
|
resolveThemeConfig,
|
||||||
|
resolveBrowserCookieOptions,
|
||||||
renderActiveThemeCss,
|
renderActiveThemeCss,
|
||||||
renderThemeCss,
|
renderThemeCss,
|
||||||
renderThemeRuntime,
|
renderThemeRuntime,
|
||||||
@@ -493,7 +494,7 @@ export async function runBuild(appRoot: string): Promise<void> {
|
|||||||
console.log(`✓ Controllers: ${controllersPath}`);
|
console.log(`✓ Controllers: ${controllersPath}`);
|
||||||
|
|
||||||
// 1a) Theme tokens + client switcher (always emitted; built-in light/dark).
|
// 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 themeCss = renderThemeCss(theme);
|
||||||
const themeJs = renderThemeRuntime(theme);
|
const themeJs = renderThemeRuntime(theme);
|
||||||
writeFileSync(join(distDir, "theme.css"), themeCss, "utf8");
|
writeFileSync(join(distDir, "theme.css"), themeCss, "utf8");
|
||||||
@@ -541,8 +542,17 @@ export async function runBuild(appRoot: string): Promise<void> {
|
|||||||
|
|
||||||
// 1a4) i18n: bake locale messages into the manifest (opt-in via app/locales).
|
// 1a4) i18n: bake locale messages into the manifest (opt-in via app/locales).
|
||||||
const localeMessages = loadLocales(join(appDir, "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
|
const i18n = Object.keys(localeMessages).length
|
||||||
? resolveI18n(localeMessages, config.i18n)
|
? resolveI18n(localeMessages, i18nConfig)
|
||||||
: undefined;
|
: undefined;
|
||||||
if (i18n) console.log(`✓ i18n: ${i18n.langs.length} locales (default: ${i18n.default})`);
|
if (i18n) console.log(`✓ i18n: ${i18n.langs.length} locales (default: ${i18n.default})`);
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@wrnexus/dev-server",
|
"name": "@wrnexus/dev-server",
|
||||||
"version": "0.8.27",
|
"version": "0.8.28",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "src/index.ts",
|
"main": "src/index.ts",
|
||||||
"exports": {
|
"exports": {
|
||||||
|
|||||||
@@ -12,6 +12,8 @@ import type { Middleware, Mode, SecurityConfig, SeoConfig } from "@wrnexus/core"
|
|||||||
import { buildRouter, type Router } from "@wrnexus/router";
|
import { buildRouter, type Router } from "@wrnexus/router";
|
||||||
import {
|
import {
|
||||||
resolveThemeConfig,
|
resolveThemeConfig,
|
||||||
|
resolveBrowserCookieOptions,
|
||||||
|
type BrowserCookiesConfig,
|
||||||
type StylesConfig,
|
type StylesConfig,
|
||||||
type ThemeConfig,
|
type ThemeConfig,
|
||||||
type MobileConfig,
|
type MobileConfig,
|
||||||
@@ -101,6 +103,8 @@ export interface ServeOptions {
|
|||||||
security?: SecurityConfig;
|
security?: SecurityConfig;
|
||||||
/** Design-token theme config (merged over the built-in light/dark). */
|
/** Design-token theme config (merged over the built-in light/dark). */
|
||||||
theme?: ThemeConfig;
|
theme?: ThemeConfig;
|
||||||
|
/** Shared browser-cookie defaults and preference-specific overrides. */
|
||||||
|
cookies?: BrowserCookiesConfig;
|
||||||
/** i18n config (default language + supported locales). */
|
/** i18n config (default language + supported locales). */
|
||||||
i18n?: I18nConfig;
|
i18n?: I18nConfig;
|
||||||
/** Default database connection (driver + url). Enables `getDb()` and dev auto-migrate. */
|
/** Default database connection (driver + url). Enables `getDb()` and dev auto-migrate. */
|
||||||
@@ -355,7 +359,7 @@ export async function startServer(opts: ServeOptions): Promise<RunningServer> {
|
|||||||
// Compile every `.wrn` into ONE cache dir at the project root, instead of a
|
// 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).
|
// `.wrnexus/` next to each source file (and inside node_modules UI dirs).
|
||||||
setCompileCacheDir(cacheDir);
|
setCompileCacheDir(cacheDir);
|
||||||
const theme = resolveThemeConfig(opts.theme);
|
const theme = resolveThemeConfig(opts.theme, opts.cookies);
|
||||||
const uiStylesPath = uiCssPath();
|
const uiStylesPath = uiCssPath();
|
||||||
|
|
||||||
const schemasJs = await schemaRuntime(router);
|
const schemasJs = await schemaRuntime(router);
|
||||||
@@ -375,8 +379,17 @@ export async function startServer(opts: ServeOptions): Promise<RunningServer> {
|
|||||||
|
|
||||||
// i18n is opt-in by the presence of app/locales/*.json.
|
// i18n is opt-in by the presence of app/locales/*.json.
|
||||||
const localeMessages = loadLocales(join(appDir, "locales"), { strict: opts.i18n?.strict });
|
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
|
const i18n = Object.keys(localeMessages).length
|
||||||
? resolveI18n(localeMessages, opts.i18n)
|
? resolveI18n(localeMessages, i18nConfig)
|
||||||
: undefined;
|
: undefined;
|
||||||
|
|
||||||
// Databases: configure the default (getDb()) + each named one (getDb("<name>")),
|
// Databases: configure the default (getDb()) + each named one (getDb("<name>")),
|
||||||
|
|||||||
@@ -54,6 +54,7 @@ const server = await startServer({
|
|||||||
seo: config.seo,
|
seo: config.seo,
|
||||||
security: config.security,
|
security: config.security,
|
||||||
theme: config.theme,
|
theme: config.theme,
|
||||||
|
cookies: config.cookies,
|
||||||
i18n: config.i18n,
|
i18n: config.i18n,
|
||||||
db: config.db,
|
db: config.db,
|
||||||
databases: config.databases,
|
databases: config.databases,
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@wrnexus/i18n",
|
"name": "@wrnexus/i18n",
|
||||||
"version": "0.8.9",
|
"version": "0.8.10",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "./src/index.ts",
|
"main": "./src/index.ts",
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ export interface LocaleLoadOptions {
|
|||||||
export interface I18nCookieConfig {
|
export interface I18nCookieConfig {
|
||||||
name?: string;
|
name?: string;
|
||||||
maxAge?: number;
|
maxAge?: number;
|
||||||
|
domain?: string;
|
||||||
path?: string;
|
path?: string;
|
||||||
sameSite?: "Strict" | "Lax" | "None";
|
sameSite?: "Strict" | "Lax" | "None";
|
||||||
secure?: boolean;
|
secure?: boolean;
|
||||||
@@ -253,6 +254,7 @@ export function resolveI18n(
|
|||||||
cookie: {
|
cookie: {
|
||||||
name: config.cookie?.name ?? LANG_COOKIE,
|
name: config.cookie?.name ?? LANG_COOKIE,
|
||||||
maxAge: config.cookie?.maxAge ?? 31_536_000,
|
maxAge: config.cookie?.maxAge ?? 31_536_000,
|
||||||
|
domain: config.cookie?.domain ?? "",
|
||||||
path: config.cookie?.path ?? "/",
|
path: config.cookie?.path ?? "/",
|
||||||
sameSite: config.cookie?.sameSite ?? "Lax",
|
sameSite: config.cookie?.sameSite ?? "Lax",
|
||||||
secure: config.cookie?.sameSite === "None" ? true : (config.cookie?.secure ?? false),
|
secure: config.cookie?.sameSite === "None" ? true : (config.cookie?.secure ?? false),
|
||||||
@@ -464,10 +466,16 @@ export const I18N_RUNTIME = String.raw`
|
|||||||
var current = state();
|
var current = state();
|
||||||
if (current.langs && current.langs.indexOf(lang) === -1) return false;
|
if (current.langs && current.langs.indexOf(lang) === -1) return false;
|
||||||
var cookie = current.cookie || {};
|
var cookie = current.cookie || {};
|
||||||
var secure = cookie.secure || location.protocol === "https:";
|
var cookieName = cookie.name || "${LANG_COOKIE}";
|
||||||
document.cookie = encodeURIComponent(cookie.name || "${LANG_COOKIE}") + "=" + encodeURIComponent(lang) +
|
if (window.wrnCookies && typeof window.wrnCookies.set === "function") {
|
||||||
";path=" + (cookie.path || "/") + ";max-age=" + (cookie.maxAge || 31536000) +
|
window.wrnCookies.set(cookieName, lang, "language", cookie);
|
||||||
";samesite=" + (cookie.sameSite || "Lax") + (secure ? ";secure" : "");
|
} 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.lang = lang;
|
||||||
document.documentElement.dir = (current.directions && current.directions[lang]) || "ltr";
|
document.documentElement.dir = (current.directions && current.directions[lang]) || "ltr";
|
||||||
window.dispatchEvent(new CustomEvent("wrnexus:language-change", { detail: { locale: lang } }));
|
window.dispatchEvent(new CustomEvent("wrnexus:language-change", { detail: { locale: lang } }));
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { test, expect } from "bun:test";
|
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(
|
const i18n = resolveI18n(
|
||||||
{
|
{
|
||||||
@@ -27,6 +27,10 @@ test("resolveLang: cookie → Accept-Language → default", () => {
|
|||||||
expect(resolveLang(i18n, "xx", "de")).toBe("en"); // invalid cookie + unsupported header
|
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", () => {
|
test("translateHtml resolves data-t text and t:attr attributes", () => {
|
||||||
const t = makeT(i18n, "en");
|
const t = makeT(i18n, "en");
|
||||||
const out = translateHtml('<input t:placeholder="ph"><span data-t="nav.home"></span>', t);
|
const out = translateHtml('<input t:placeholder="ph"><span data-t="nav.home"></span>', t);
|
||||||
|
|||||||
@@ -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.
|
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
|
## Usage
|
||||||
|
|
||||||
### `wrnexus.config.ts`
|
### `wrnexus.config.ts`
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@wrnexus/styles",
|
"name": "@wrnexus/styles",
|
||||||
"version": "0.8.14",
|
"version": "0.8.15",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "src/index.ts",
|
"main": "src/index.ts",
|
||||||
"exports": {
|
"exports": {
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ import { pathToFileURL } from "node:url";
|
|||||||
import type { PerformanceBudgets, SecurityConfig, SeoConfig } from "@wrnexus/core";
|
import type { PerformanceBudgets, SecurityConfig, SeoConfig } from "@wrnexus/core";
|
||||||
import type { PluginInput, PluginPermission } from "@wrnexus/plugin";
|
import type { PluginInput, PluginPermission } from "@wrnexus/plugin";
|
||||||
import type { StorageConfig } from "@wrnexus/uploader";
|
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 type { FontConfig } from "./fonts.ts";
|
||||||
import { fontCspSources } from "./fonts.ts";
|
import { fontCspSources } from "./fonts.ts";
|
||||||
import {
|
import {
|
||||||
@@ -299,6 +299,8 @@ export interface AppConfig extends CompatibilityPolicy {
|
|||||||
fonts?: FontConfig;
|
fonts?: FontConfig;
|
||||||
/** Design-token themes (deep-merged over the built-in light/dark). */
|
/** Design-token themes (deep-merged over the built-in light/dark). */
|
||||||
theme?: ThemeConfig;
|
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 language + supported locales (strings live in app/locales/*.json). */
|
||||||
i18n?: {
|
i18n?: {
|
||||||
default?: string;
|
default?: string;
|
||||||
@@ -309,6 +311,7 @@ export interface AppConfig extends CompatibilityPolicy {
|
|||||||
cookie?: {
|
cookie?: {
|
||||||
name?: string;
|
name?: string;
|
||||||
maxAge?: number;
|
maxAge?: number;
|
||||||
|
domain?: string;
|
||||||
path?: string;
|
path?: string;
|
||||||
sameSite?: "Strict" | "Lax" | "None";
|
sameSite?: "Strict" | "Lax" | "None";
|
||||||
secure?: boolean;
|
secure?: boolean;
|
||||||
|
|||||||
@@ -54,6 +54,10 @@ export type {
|
|||||||
CustomThemePalette,
|
CustomThemePalette,
|
||||||
ThemeToken,
|
ThemeToken,
|
||||||
ThemeSemanticColor,
|
ThemeSemanticColor,
|
||||||
|
BrowserCookieOptions,
|
||||||
|
BrowserCookiesConfig,
|
||||||
|
BrowserCookiePreference,
|
||||||
|
BrowserCookieApi,
|
||||||
} from "./theme.ts";
|
} from "./theme.ts";
|
||||||
export {
|
export {
|
||||||
DEFAULT_THEMES,
|
DEFAULT_THEMES,
|
||||||
@@ -71,6 +75,7 @@ export {
|
|||||||
renderThemeRuntime,
|
renderThemeRuntime,
|
||||||
defineThemeTokens,
|
defineThemeTokens,
|
||||||
themeVar,
|
themeVar,
|
||||||
|
resolveBrowserCookieOptions,
|
||||||
} from "./theme.ts";
|
} from "./theme.ts";
|
||||||
|
|
||||||
import type { Mode, StyleProcessContext, StylesConfig } from "./config.ts";
|
import type { Mode, StyleProcessContext, StylesConfig } from "./config.ts";
|
||||||
|
|||||||
@@ -111,6 +111,54 @@ export interface ThemeCookieConfig {
|
|||||||
sameSite?: "Strict" | "Lax" | "None";
|
sameSite?: "Strict" | "Lax" | "None";
|
||||||
/** Force or disable Secure. When omitted it follows the current protocol. */
|
/** Force or disable Secure. When omitted it follows the current protocol. */
|
||||||
secure?: boolean;
|
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 {
|
export interface ThemeConfig {
|
||||||
@@ -131,6 +179,8 @@ export interface ResolvedTheme {
|
|||||||
defaultAccent?: ThemePaletteName;
|
defaultAccent?: ThemePaletteName;
|
||||||
accentNames: ThemePaletteName[];
|
accentNames: ThemePaletteName[];
|
||||||
accentCookie?: ThemeCookieConfig;
|
accentCookie?: ThemeCookieConfig;
|
||||||
|
themeCookie?: ThemeCookieConfig;
|
||||||
|
cookies?: BrowserCookiesConfig;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Cookies used by the SSR renderer and client runtime. */
|
/** 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. */
|
/** 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 palette = resolvePalette(config?.palette);
|
||||||
const themes: Record<string, ThemeTokens> = {};
|
const themes: Record<string, ThemeTokens> = {};
|
||||||
const names = new Set<string>([
|
const names = new Set<string>([
|
||||||
@@ -508,7 +561,9 @@ export function resolveThemeConfig(config?: ThemeConfig): ResolvedTheme {
|
|||||||
themes,
|
themes,
|
||||||
defaultAccent,
|
defaultAccent,
|
||||||
accentNames,
|
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 accentNames = JSON.stringify(theme.accentNames);
|
||||||
const defaultAccent = JSON.stringify(theme.defaultAccent ?? null);
|
const defaultAccent = JSON.stringify(theme.defaultAccent ?? null);
|
||||||
const cssPrefix = JSON.stringify(THEME_CSS_PREFIX);
|
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 ?? {});
|
const accentCookie = JSON.stringify(theme.accentCookie ?? {});
|
||||||
|
|
||||||
return `(function(){
|
return `(function(){
|
||||||
@@ -647,6 +704,8 @@ export function renderThemeRuntime(theme: ResolvedTheme): string {
|
|||||||
var DEFAULT_THEME=${JSON.stringify(theme.default)};
|
var DEFAULT_THEME=${JSON.stringify(theme.default)};
|
||||||
var DEFAULT_ACCENT=${defaultAccent};
|
var DEFAULT_ACCENT=${defaultAccent};
|
||||||
var THEME_CSS_PREFIX=${cssPrefix};
|
var THEME_CSS_PREFIX=${cssPrefix};
|
||||||
|
var COOKIE_CONFIG=${cookieConfig};
|
||||||
|
var THEME_COOKIE_OPTIONS=${themeCookie};
|
||||||
var ACCENT_COOKIE_OPTIONS=${accentCookie};
|
var ACCENT_COOKIE_OPTIONS=${accentCookie};
|
||||||
var MAX_AGE=31536000;
|
var MAX_AGE=31536000;
|
||||||
var el=document.documentElement;
|
var el=document.documentElement;
|
||||||
@@ -678,14 +737,36 @@ export function renderThemeRuntime(theme: ResolvedTheme): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function writeCookie(name,value,options){
|
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)+
|
document.cookie=encodeURIComponent(name)+"="+encodeURIComponent(value)+
|
||||||
";max-age="+MAX_AGE+cookieOptions(options);
|
";max-age="+maxAge+cookieOptions(options);
|
||||||
}
|
}
|
||||||
|
|
||||||
function deleteCookie(name,options){
|
function deleteCookie(name,options){
|
||||||
document.cookie=encodeURIComponent(name)+"=;max-age=0"+cookieOptions(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){
|
function syncStylesheet(themeName,accentName){
|
||||||
var link=document.querySelector("link[data-wrnexus-theme]");
|
var link=document.querySelector("link[data-wrnexus-theme]");
|
||||||
if(!link)return;
|
if(!link)return;
|
||||||
@@ -720,7 +801,7 @@ export function renderThemeRuntime(theme: ResolvedTheme): string {
|
|||||||
function setTheme(name){
|
function setTheme(name){
|
||||||
if(THEMES.indexOf(name)<0)return;
|
if(THEMES.indexOf(name)<0)return;
|
||||||
el.setAttribute("data-theme",name);
|
el.setAttribute("data-theme",name);
|
||||||
writeCookie(THEME_COOKIE,name);
|
writeCookie(THEME_COOKIE,name,THEME_COOKIE_OPTIONS);
|
||||||
syncStylesheet(name,getAccent());
|
syncStylesheet(name,getAccent());
|
||||||
syncThemeButtons(document);
|
syncThemeButtons(document);
|
||||||
window.dispatchEvent(new CustomEvent("wrnexus:theme-changed",{detail:{theme:name}}));
|
window.dispatchEvent(new CustomEvent("wrnexus:theme-changed",{detail:{theme:name}}));
|
||||||
|
|||||||
@@ -47,6 +47,28 @@ describe("theme accents", () => {
|
|||||||
expect(runtime).not.toContain("style.setProperty");
|
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", () => {
|
test("runtime scopes accent cookies to a configured parent domain", () => {
|
||||||
const theme = resolveThemeConfig({
|
const theme = resolveThemeConfig({
|
||||||
palette: "rose",
|
palette: "rose",
|
||||||
|
|||||||
Reference in New Issue
Block a user