first commit

This commit is contained in:
2026-07-12 15:55:18 +05:30
commit ee98026cc5
404 changed files with 44522 additions and 0 deletions
+291
View File
@@ -0,0 +1,291 @@
/**
* App configuration loader (`wrnexus.config.ts`).
*
* The config is optional. It lets an app inject arbitrary `<head>` HTML (ideal
* for CDN-delivered CSS frameworks like Bootstrap or the Tailwind Play CDN) and
* customise the global stylesheet pipeline (entry file or a custom processor for
* Tailwind / PostCSS / Sass).
*/
import { existsSync, readFileSync } from "node:fs";
import { join } from "node:path";
import { pathToFileURL } from "node:url";
import type { SecurityConfig, SeoConfig } from "@wrnexus/core";
import type { StorageConfig } from "@wrnexus/uploader";
import type { ThemeConfig } from "./theme.ts";
import type { FontConfig } from "./fonts.ts";
import { fontCspSources } from "./fonts.ts";
export type Mode = "development" | "production";
export interface StyleProcessContext {
/** Resolved absolute path to the CSS entry, or null if there is none. */
entryPath: string | null;
appDir: string;
appRoot: string;
mode: Mode;
}
export interface StylesConfig {
/** Path to the CSS entry, relative to the app root. Default: app/styles/global.css */
entry?: string;
/**
* Optional custom processor. Return the final CSS string. Use this to run
* Tailwind, PostCSS, Sass, etc. When omitted, the built-in Bun CSS bundler is
* used (which already resolves `@import`, including from node_modules).
*/
process?: (ctx: StyleProcessContext) => string | Promise<string>;
}
export interface MobileConfig {
enabled?: boolean;
/** Mobile renderer. `webview` uses Capacitor; `native` scaffolds an Expo/React Native app. */
mode?: "webview" | "native";
appId?: string;
appName?: string;
serverUrl?: string;
userAgent?: string;
layout?: string;
backgroundColor?: string;
icon?: string;
errorTitle?: string;
errorMessage?: string;
/** Base URL used by a fully native client for WrNexus API and realtime requests. */
apiUrl?: string;
/** URL scheme used for native deep links (defaults to a slug of appName). */
scheme?: string;
/** Advanced Expo app config fields merged into generated app.config.ts. */
expo?: Record<string, unknown>;
/** Advanced CapacitorConfig fields merged into generated capacitor.config.ts. */
capacitor?: Record<string, unknown>;
}
export interface PwaScreenshot {
src: string;
sizes: string;
type?: string;
formFactor?: "wide" | "narrow";
label?: string;
}
export interface PwaShortcut {
name: string;
shortName?: string;
description?: string;
url: string;
icons?: Array<{ src: string; sizes: string; type?: string; purpose?: string }>;
}
export interface PwaConfig {
enabled?: boolean;
id?: string;
name?: string;
shortName?: string;
description?: string;
startUrl?: string;
scope?: string;
lang?: string;
display?: "standalone" | "fullscreen" | "minimal-ui" | "browser";
orientation?:
| "any"
| "natural"
| "landscape"
| "landscape-primary"
| "landscape-secondary"
| "portrait"
| "portrait-primary"
| "portrait-secondary";
themeColor?: string;
backgroundColor?: string;
icons?: Array<{ src: string; sizes: string; type?: string; purpose?: string }>;
categories?: string[];
screenshots?: PwaScreenshot[];
shortcuts?: PwaShortcut[];
/** Disable service-worker registration while keeping the web manifest. */
serviceWorker?: boolean;
/** Navigation shown when both the network and requested page cache are unavailable. */
offlineUrl?: string;
/** Additional same-origin URLs precached during service-worker installation. */
cacheUrls?: string[];
/** Service-worker cache key. Change it to invalidate existing PWA caches. */
cacheName?: string;
}
export interface AppConfig {
/** Raw HTML appended to every page's `<head>` (e.g. CDN stylesheet links). */
head?: string | string[];
/** Global SEO defaults merged with every page's exported `meta`. */
seo?: SeoConfig;
/** Framework security headers and optional CORS policy. */
security?: SecurityConfig;
styles?: StylesConfig;
/** Capacitor/native shell defaults and mobile-only page rendering. */
mobile?: MobileConfig;
/** Progressive Web App metadata. Enabled by default unless set to false. */
pwa?: PwaConfig | false;
/**
* Fonts. Declare Google Fonts (subsetted + preconnect + `font-display`) and/or
* self-hosted `@font-face` (with preload), and set `sans`/`mono`/`serif` family
* stacks. Google Fonts auto-extend the CSP so they load under the default policy.
*/
fonts?: FontConfig;
/** Design-token themes (deep-merged over the built-in light/dark). */
theme?: ThemeConfig;
/** i18n: default language + supported locales (strings live in app/locales/*.json). */
i18n?: { default?: string; locales?: string[] };
/** Default database connection (driver + url); reached with `getDb()`. */
db?: { driver: "sqlite" | "postgres" | "mysql" | "mongo"; url: string };
/**
* File-upload storage. Declare named stores (local dir or S3-compatible),
* upload with `handleUpload`/`upload` from `@wrnexus/uploader`, and serve
* files back. Each store is `access: "public" | "private"`.
*/
storage?: StorageConfig;
/**
* Additional named databases, reached with `getDb("<name>")`. Each has its own
* migrations/queries under `app/db/<name>/`. Connect to as many as you like and
* read/write to any of them per request.
*
* databases: { analytics: { driver: "postgres", url: "…" } }
*/
databases?: Record<string, { driver: "sqlite" | "postgres" | "mysql" | "mongo"; url: string }>;
/**
* Realtime scaling. When `scale` is true (or `redisUrl` is set), room
* broadcasts are bridged over Redis pub/sub so they reach clients on **every**
* app process/instance — realtime that works with multiple running apps.
*/
realtime?: { scale?: boolean; redisUrl?: string };
/** Default server port. */
port?: number;
/**
* Named config profiles (dev, prod, uat, test, …). When a profile is active
* its overrides are DEEP-MERGED over the base config. Select with
* `--profile=<name>` or the `WRNEXUS_PROFILE` env var.
*/
profiles?: Record<string, Partial<Omit<AppConfig, "profiles">>>;
}
const CONFIG_NAMES = ["wrnexus.config.ts", "wrnexus.config.js", "wrnexus.config.mjs"];
/**
* Resolve the active profile name: explicit argument > `WRNEXUS_PROFILE` env var
* > a mode-based default ("production" in prod, else "development").
*/
export function resolveProfile(options: { explicit?: string; mode?: Mode } = {}): string {
const env = (globalThis as { process?: { env?: Record<string, string | undefined> } }).process
?.env;
return (
options.explicit ||
env?.WRNEXUS_PROFILE ||
(options.mode === "production" ? "production" : "development")
);
}
function isPlainObject(value: unknown): value is Record<string, unknown> {
return !!value && typeof value === "object" && !Array.isArray(value);
}
/** Deep-merge `override` onto `base` (objects merge; arrays/scalars replace). */
function deepMerge<T>(base: T, override: unknown): T {
if (!isPlainObject(base) || !isPlainObject(override)) return (override ?? base) as T;
const out: Record<string, unknown> = { ...base };
for (const [key, value] of Object.entries(override)) {
out[key] = key in out ? deepMerge(out[key], value) : value;
}
return out as T;
}
/** Load the raw `wrnexus.config.*` (with the `profiles` map intact), or `{}`. */
export async function loadRawConfig(appRoot: string): Promise<AppConfig> {
for (const name of CONFIG_NAMES) {
const file = join(appRoot, name);
if (existsSync(file)) {
const mod = (await import(pathToFileURL(file).href)) as { default?: AppConfig };
return mod.default ?? {};
}
}
return {};
}
/** Load `wrnexus.config.*`, applying the active profile's overrides. */
export async function loadAppConfig(appRoot: string, profile?: string): Promise<AppConfig> {
const base = await loadRawConfig(appRoot);
const active = profile ?? resolveProfile();
const override = base.profiles?.[active];
const merged: AppConfig = override ? deepMerge(base, override) : { ...base };
delete merged.profiles;
applyFontCsp(merged);
return merged;
}
/**
* Auto-extend the CSP so configured Google Fonts load under the default policy
* (their CSS host into `style-src`, the static host into `font-src`). No-op when
* the app disabled CSP (`security.contentSecurityPolicy: false`) or uses no
* Google Fonts. Local (self-hosted) fonts are served from `'self'` and need nothing.
*/
function applyFontCsp(config: AppConfig): void {
const add = fontCspSources(config.fonts);
if (!add.style.length && !add.font.length) return;
const security = (config.security ??= {});
if (security.contentSecurityPolicy === false) return;
const csp = security.contentSecurityPolicy ?? {};
security.contentSecurityPolicy = csp;
const dirs = csp.directives ?? {};
csp.directives = dirs;
const extend = (name: string, base: string[], adds: string[]) => {
const cur = Array.isArray(dirs[name]) ? (dirs[name] as string[]) : base;
dirs[name] = Array.from(new Set([...cur, ...adds]));
};
if (add.style.length) extend("style-src", ["'self'", "'unsafe-inline'"], add.style);
if (add.font.length) extend("font-src", ["'self'", "data:"], add.font);
}
/**
* Load the `.env` cascade for a profile into `process.env`, WITHOUT clobbering
* variables already set in the real environment (which always win). Order, low
* → high precedence: `.env` < `.env.<profile>` < `.env.local` < `.env.<profile>.local`.
* Returns the variables it loaded.
*/
export function loadEnv(appRoot: string, profile: string): Record<string, string> {
const proc = (globalThis as { process?: { env: Record<string, string | undefined> } }).process;
const env = proc?.env ?? {};
const realKeys = new Set(Object.keys(env));
const loaded: Record<string, string> = {};
for (const name of [".env", `.env.${profile}`, ".env.local", `.env.${profile}.local`]) {
const file = join(appRoot, name);
if (!existsSync(file)) continue;
for (const [key, value] of Object.entries(parseDotenv(readFileSync(file, "utf8")))) {
loaded[key] = value;
if (!realKeys.has(key)) env[key] = value; // never override real env
}
}
return loaded;
}
function parseDotenv(content: string): Record<string, string> {
const out: Record<string, string> = {};
for (const line of content.split(/\r?\n/)) {
const m = /^\s*(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.*)$/.exec(line);
if (!m) continue;
let value = m[2]!.trim();
if (
(value.startsWith('"') && value.endsWith('"')) ||
(value.startsWith("'") && value.endsWith("'"))
) {
const quoted = value.slice(1, -1);
value = value[0] === '"' ? quoted.replace(/\\n/g, "\n").replace(/\\t/g, "\t") : quoted;
} else {
const comment = value.indexOf(" #"); // strip trailing comments on unquoted values
if (comment >= 0) value = value.slice(0, comment).trim();
}
out[m[1]!] = value;
}
return out;
}
/** Flatten a head config into a single HTML string. */
export function headToString(head?: string | string[]): string {
if (!head) return "";
return Array.isArray(head) ? head.filter(Boolean).join("\n ") : head;
}
+185
View File
@@ -0,0 +1,185 @@
/**
* Font configuration.
*
* Declare fonts in `wrnexus.config.ts` under `fonts` and the framework emits
* optimized `<head>` markup for you:
* - Google Fonts: `preconnect` hints + a single subsetted stylesheet request
* (only the weights you list) with `font-display`. The CSP is auto-extended
* so the fonts load under the default security policy (see loadAppConfig).
* - Self-hosted fonts: generated `@font-face` rules + optional `<link rel=preload>`
* for above-the-fold text (the fastest, no-third-party option).
* - Family stacks: `sans`/`mono`/`serif` become `--wrn-font-*` CSS variables,
* and `sans` is applied to `body`.
*/
export type FontDisplay = "auto" | "block" | "swap" | "fallback" | "optional";
export interface GoogleFont {
/** Family name as it appears on fonts.google.com, e.g. "Inter". */
family: string;
/** Weights to load — ONLY these are fetched. Default: [400]. */
weights?: (number | string)[];
/** Also load italic styles for each weight. */
italic?: boolean;
/** Per-font `font-display` override (else the config default). */
display?: FontDisplay;
}
export interface LocalFontFace {
/** `font-family` name this face defines. */
family: string;
/** URL to the font file, typically served from `public/` (e.g. "/fonts/inter.woff2"). */
src: string;
/** e.g. 400, "700", or "100 900" for a variable font. Default: 400. */
weight?: number | string;
style?: "normal" | "italic";
/** CSS `src` format; inferred from the file extension when omitted. */
format?: string;
display?: FontDisplay;
/** Emit `<link rel="preload" as="font">` — use for the primary above-the-fold face. */
preload?: boolean;
/** Optional `unicode-range` subset. */
unicodeRange?: string;
}
export interface FontConfig {
/** Google Fonts, loaded with preconnect + weight subsetting + `font-display`. */
google?: GoogleFont[];
/** Self-hosted `@font-face` definitions (files served from `public/`). */
local?: LocalFontFace[];
/** Default `font-display` for faces that don't set their own. Default: "swap". */
display?: FontDisplay;
/** Body / default family stack → `--wrn-font-sans` + `body { font-family }`. */
sans?: string;
/** Monospace family stack → `--wrn-font-mono`. */
mono?: string;
/** Serif family stack → `--wrn-font-serif`. */
serif?: string;
}
const GOOGLE_CSS = "https://fonts.googleapis.com";
const GOOGLE_STATIC = "https://fonts.gstatic.com";
function escAttr(s: string): string {
return s
.replace(/&/g, "&amp;")
.replace(/"/g, "&quot;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;");
}
function cssString(s: string): string {
return s
.replace(/\\/g, "\\\\")
.replace(/"/g, '\\"')
.replace(/[\r\n\f]/g, " ");
}
function safeStyle(css: string): string {
return css.replace(/<\/style/gi, "<\\/style");
}
function extOf(src: string): string {
return (src.split(/[?#]/)[0].split(".").pop() ?? "").toLowerCase();
}
function cssFormat(src: string, override?: string): string {
if (override) return override;
const e = extOf(src);
return e === "woff2"
? "woff2"
: e === "woff"
? "woff"
: e === "ttf"
? "truetype"
: e === "otf"
? "opentype"
: "woff2";
}
function preloadType(src: string): string {
const e = extOf(src);
return e === "woff"
? "font/woff"
: e === "ttf"
? "font/ttf"
: e === "otf"
? "font/otf"
: "font/woff2";
}
/** Build the Google Fonts `css2` URL for the given families (weights subsetted). */
function googleFontsUrl(fonts: GoogleFont[], defDisplay: FontDisplay): string {
const families = fonts.map((f) => {
const name = encodeURIComponent(f.family).replace(/%20/g, "+");
const weights = (f.weights?.length ? f.weights : [400]).map(String);
if (f.italic) {
const pairs = weights.flatMap((w) => [`0,${w}`, `1,${w}`]).sort((a, b) => a.localeCompare(b));
return `family=${name}:ital,wght@${pairs.join(";")}`;
}
const sorted = [...weights].sort((a, b) => Number(a) - Number(b));
return `family=${name}:wght@${sorted.join(";")}`;
});
return `${GOOGLE_CSS}/css2?${families.join("&")}&display=${defDisplay}`;
}
/**
* Render all `<head>` markup for a font config. Returns "" when nothing is
* configured. The output is trusted, framework-controlled HTML.
*/
export function renderFontHead(fonts?: FontConfig): string {
if (!fonts) return "";
const display = fonts.display ?? "swap";
const out: string[] = [];
// Google Fonts — preconnect (perf) then one subsetted stylesheet.
if (fonts.google?.length) {
out.push(`<link rel="preconnect" href="${GOOGLE_CSS}">`);
out.push(`<link rel="preconnect" href="${GOOGLE_STATIC}" crossorigin>`);
out.push(`<link rel="stylesheet" href="${escAttr(googleFontsUrl(fonts.google, display))}">`);
}
// Self-hosted @font-face + optional preload.
if (fonts.local?.length) {
const faces = fonts.local.map((f) => {
const lines = [
` font-family: "${cssString(f.family)}";`,
` src: url("${cssString(f.src)}") format("${cssString(cssFormat(f.src, f.format))}");`,
` font-weight: ${f.weight ?? 400};`,
` font-style: ${f.style ?? "normal"};`,
` font-display: ${f.display ?? display};`,
];
if (f.unicodeRange) lines.push(` unicode-range: ${f.unicodeRange};`);
return `@font-face {\n${lines.join("\n")}\n}`;
});
out.push(`<style>\n${safeStyle(faces.join("\n"))}\n</style>`);
for (const f of fonts.local) {
if (f.preload) {
out.push(
`<link rel="preload" href="${escAttr(f.src)}" as="font" type="${preloadType(f.src)}" crossorigin>`,
);
}
}
}
// Family stacks → CSS variables + body default.
const vars: string[] = [];
if (fonts.sans) vars.push(` --wrn-font-sans: ${fonts.sans};`);
if (fonts.mono) vars.push(` --wrn-font-mono: ${fonts.mono};`);
if (fonts.serif) vars.push(` --wrn-font-serif: ${fonts.serif};`);
if (vars.length) {
const body = fonts.sans ? `\nbody { font-family: var(--wrn-font-sans); }` : "";
out.push(`<style>\n${safeStyle(`:root {\n${vars.join("\n")}\n}${body}`)}\n</style>`);
}
return out.join("\n ");
}
/**
* CSP source hosts required by the configured fonts, so the policy can be
* auto-extended (Google Fonts need their CSS + static hosts allow-listed).
*/
export function fontCspSources(fonts?: FontConfig): { style: string[]; font: string[] } {
if (fonts?.google?.length) return { style: [GOOGLE_CSS], font: [GOOGLE_STATIC] };
return { style: [], font: [] };
}
+85
View File
@@ -0,0 +1,85 @@
/**
* @wrnexus/styles — global stylesheet pipeline + app config.
*
* Works for SSR and CSR: the bundled stylesheet is `<link>`ed into every page's
* `<head>`, so it styles server-rendered markup and hydrated client islands
* alike. Use any CSS framework via `@import` in global.css (npm) or via a CDN
* link in `wrnexus.config.ts`'s `head` field.
*/
export type {
AppConfig,
MobileConfig,
PwaConfig,
StylesConfig,
StyleProcessContext,
Mode,
} from "./config.ts";
export { loadAppConfig, loadRawConfig, headToString, resolveProfile, loadEnv } from "./config.ts";
export { findStyleEntry, bundleCss } from "./styles.ts";
export type { FontConfig, GoogleFont, LocalFontFace, FontDisplay } from "./fonts.ts";
export { renderFontHead, fontCspSources } from "./fonts.ts";
export type { ThemeConfig, ThemeTokens, ResolvedTheme } from "./theme.ts";
export {
DEFAULT_THEMES,
THEME_COOKIE,
THEME_CSS_HREF,
THEME_JS_HREF,
resolveThemeConfig,
resolveThemeName,
renderThemeCss,
renderThemeRuntime,
} from "./theme.ts";
import type { Mode, StyleProcessContext, StylesConfig } from "./config.ts";
import { bundleCss } from "./styles.ts";
/**
* Produce the final CSS for an entry: run the config's custom processor if one
* is provided (Tailwind/PostCSS/Sass), otherwise use the built-in Bun bundler.
*
* If a custom processor throws (e.g. Tailwind can't resolve `tailwindcss`
* because deps aren't installed), we DON'T crash every request — we log a clear,
* actionable message and fall back to best-effort CSS so the app keeps serving.
*/
export async function renderStyles(
ctx: StyleProcessContext,
styles?: StylesConfig,
): Promise<string> {
if (!ctx.entryPath) return "";
if (styles?.process) {
try {
return String(await styles.process(ctx));
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
console.error(
`\n[wrnexus] Stylesheet processing failed — serving un-processed CSS.\n` +
` ${msg.split("\n")[0]}\n` +
` If you use Tailwind, run \`bun install\` inside the app so \`tailwindcss\`\n` +
` is available (an app created inside another project may not have it).\n`,
);
return await fallbackCss(ctx);
}
}
return bundleCss(ctx.entryPath, ctx.mode);
}
/**
* Best-effort CSS when a custom processor fails: try the built-in bundler; if that
* also fails (e.g. `@import "tailwindcss"` can't resolve), serve the raw entry with
* the tool-only directives stripped so the page still renders.
*/
async function fallbackCss(ctx: StyleProcessContext): Promise<string> {
try {
return await bundleCss(ctx.entryPath!, ctx.mode);
} catch {
try {
const raw = await Bun.file(ctx.entryPath!).text();
return raw.replace(/@import\s+["']tailwindcss["'];?/g, "").replace(/@source[^;\n]*;?/g, "");
} catch {
return "";
}
}
}
export type { Mode as StylesMode };
+67
View File
@@ -0,0 +1,67 @@
/**
* Global stylesheet pipeline.
*
* Convention: `app/styles/global.css` is the entry. If it is absent but other
* `app/styles/*.css` files exist, they are aggregated into one entry. The entry
* is bundled by Bun's CSS bundler, which resolves `@import` — including from
* node_modules — so any npm CSS framework (Bootstrap, etc.) works by importing
* it. A custom `process` hook can replace the bundler for Tailwind/PostCSS/Sass.
*/
import { existsSync, readdirSync, mkdirSync, writeFileSync } from "node:fs";
import { isAbsolute, join, relative } from "node:path";
import type { Mode } from "./config.ts";
const fwd = (p: string) => p.replace(/\\/g, "/");
/**
* Resolve the CSS entry for an app.
* - `override` (from config.styles.entry) is resolved relative to `appRoot`.
* - otherwise prefer `app/styles/global.css`.
* - otherwise aggregate all `app/styles/*.css` into a generated entry.
* Returns null when the app has no styles.
*/
export function findStyleEntry(appDir: string, appRoot: string, override?: string): string | null {
if (override) {
const p = isAbsolute(override) ? override : join(appRoot, override);
return existsSync(p) ? p : null;
}
const stylesDir = join(appDir, "styles");
const globalCss = join(stylesDir, "global.css");
if (existsSync(globalCss)) return globalCss;
if (existsSync(stylesDir)) {
const cssFiles = readdirSync(stylesDir)
.filter((f) => f.endsWith(".css"))
.sort();
if (cssFiles.length > 0) {
const cacheDir = join(appDir, ".wrnexus");
mkdirSync(cacheDir, { recursive: true });
const entry = join(cacheDir, "styles-entry.css");
const imports = cssFiles
.map((f) => `@import "${fwd(relative(cacheDir, join(stylesDir, f)))}";`)
.join("\n");
writeFileSync(entry, imports + "\n", "utf8");
return entry;
}
}
return null;
}
/**
* Bundle a CSS entry into a single stylesheet string using Bun's CSS bundler.
* Resolves `@import` (local and node_modules), handles nesting, minifies in prod.
*/
export async function bundleCss(entryPath: string, mode: Mode): Promise<string> {
const result = await Bun.build({
entrypoints: [entryPath],
minify: mode === "production",
});
if (!result.success) {
throw new Error("CSS bundle failed:\n" + result.logs.map(String).join("\n"));
}
const cssOutput = result.outputs.find((o) => o.path.endsWith(".css")) ?? result.outputs[0];
return await cssOutput!.text();
}
+149
View File
@@ -0,0 +1,149 @@
/**
* Theme system — design tokens that work SSR and client-side.
*
* Tokens are plain CSS custom properties (`--wire-<key>`) so they cascade and
* can be overridden by user CSS. Each theme is a flat token map; the framework
* ships default `light`/`dark` sets and the user's config deep-merges over them.
*
* The server renders `<html data-theme="…">` from the `wire-theme` cookie (no
* flash), and a tiny client runtime toggles/persists it. The reserved token key
* `color-scheme` is emitted as the native CSS property (not a variable) so form
* controls and scrollbars match the theme.
*/
export type ThemeTokens = Record<string, string>;
export interface ThemeConfig {
/** Name of the theme used when no `wire-theme` cookie is present. */
default?: string;
/** Named token maps. Deep-merged over the framework's built-in light/dark. */
themes?: Record<string, ThemeTokens>;
}
export interface ResolvedTheme {
default: string;
names: string[];
themes: Record<string, ThemeTokens>;
}
/** Cookie the resolved theme is read from / persisted to. */
export const THEME_COOKIE = "wire-theme";
export const THEME_CSS_HREF = "/__wrnexus/theme.css";
export const THEME_JS_HREF = "/__wrnexus/theme.js";
/** Built-in themes so components have tokens out of the box. */
export const DEFAULT_THEMES: Record<string, ThemeTokens> = {
light: {
"color-scheme": "light",
"color-bg": "#ffffff",
"color-surface": "#f6f7fb",
"color-surface-2": "#eceff6",
"color-text": "#0b1020",
"color-muted": "#5a6178",
"color-border": "#e2e6f0",
"color-primary": "#2563eb",
"color-primary-hover": "#1d4ed8",
"color-primary-contrast": "#ffffff",
"color-danger": "#dc2626",
"color-success": "#16a34a",
"color-warning": "#d97706",
radius: "8px",
"radius-sm": "5px",
"font-sans": "system-ui, -apple-system, Segoe UI, Roboto, sans-serif",
"shadow-1": "0 1px 2px rgba(16,24,40,0.06), 0 1px 3px rgba(16,24,40,0.1)",
},
dark: {
"color-scheme": "dark",
"color-bg": "#0b1020",
"color-surface": "#141a30",
"color-surface-2": "#1c243f",
"color-text": "#e7ecff",
"color-muted": "#9aa6d0",
"color-border": "#ffffff1f",
"color-primary": "#6c8cff",
"color-primary-hover": "#8aa2ff",
"color-primary-contrast": "#0b1020",
"color-danger": "#f87171",
"color-success": "#4ade80",
"color-warning": "#fbbf24",
radius: "10px",
"radius-sm": "6px",
"font-sans": "system-ui, -apple-system, Segoe UI, Roboto, sans-serif",
"shadow-1": "0 1px 2px rgba(0,0,0,0.3), 0 4px 16px rgba(0,0,0,0.35)",
},
};
/** Merge the user's theme config over the built-in defaults. */
export function resolveThemeConfig(config?: ThemeConfig): ResolvedTheme {
const themes: Record<string, ThemeTokens> = {};
const names = new Set<string>([
...Object.keys(DEFAULT_THEMES),
...Object.keys(config?.themes ?? {}),
]);
for (const name of names) {
themes[name] = { ...(DEFAULT_THEMES[name] ?? {}), ...(config?.themes?.[name] ?? {}) };
}
const list = Object.keys(themes);
const preferred = config?.default && themes[config.default] ? config.default : undefined;
const fallback = themes.dark ? "dark" : list[0]!;
return { default: preferred ?? fallback, names: list, themes };
}
/** Pick a valid theme name from a cookie value, falling back to the default. */
export function resolveThemeName(cookieValue: string | undefined, theme: ResolvedTheme): string {
return cookieValue && theme.themes[cookieValue] ? cookieValue : theme.default;
}
function tokensToDeclarations(tokens: ThemeTokens): string {
return Object.entries(tokens)
.map(([key, value]) =>
key === "color-scheme" ? `color-scheme:${value};` : `--wire-${key}:${value};`,
)
.join("");
}
/** Generate the theme stylesheet: a `:root` default plus one block per theme. */
export function renderThemeCss(theme: ResolvedTheme): string {
const blocks: string[] = [];
const def = theme.themes[theme.default];
if (def) blocks.push(`:root{${tokensToDeclarations(def)}}`);
for (const name of theme.names) {
blocks.push(`[data-theme="${name}"]{${tokensToDeclarations(theme.themes[name]!)}}`);
}
return blocks.join("\n") + "\n";
}
/**
* Generate the client theme runtime. It exposes `window.wireTheme` and binds
* `[data-wire-theme-toggle]` / `[data-wire-theme-set]` elements. The configured
* theme names are baked in so `toggle()` cycles through them in order.
*/
export function renderThemeRuntime(theme: ResolvedTheme): string {
const names = JSON.stringify(theme.names);
return `(function(){
var COOKIE=${JSON.stringify(THEME_COOKIE)};
var THEMES=${names};
var el=document.documentElement;
function get(){return el.getAttribute("data-theme")||${JSON.stringify(theme.default)};}
function set(name){
if(THEMES.indexOf(name)<0)return;
el.setAttribute("data-theme",name);
document.cookie=COOKIE+"="+encodeURIComponent(name)+";path=/;max-age=31536000;samesite=lax";
}
function toggle(){var i=THEMES.indexOf(get());set(THEMES[(i+1)%THEMES.length]);}
function bind(root){
(root||document).querySelectorAll("[data-wire-theme-toggle]").forEach(function(n){
if(n.__wireThemeBound)return;n.__wireThemeBound=1;
n.addEventListener("click",function(){toggle();});
});
(root||document).querySelectorAll("[data-wire-theme-set]").forEach(function(n){
if(n.__wireThemeBound)return;n.__wireThemeBound=1;
n.addEventListener("click",function(){set(n.getAttribute("data-wire-theme-set"));});
});
}
window.wireTheme={get:get,set:set,toggle:toggle,bind:bind,themes:THEMES};
if(document.readyState==="loading")document.addEventListener("DOMContentLoaded",function(){bind(document);});
else bind(document);
})();
`;
}