15 KiB
@wrnexus/styles
Global CSS bundling, the
--wire-*design-token theme system, and thewrnexus.config.tsapp-config loader for WrNexus apps.
Part of the WrNexus framework — an SSR-first, Bun-native full-stack web framework.
Overview
This package owns three server-side concerns that shape every page a WrNexus app renders:
- Global stylesheet pipeline — finds
app/styles/global.css(or aggregatesapp/styles/*.css), bundles it with Bun's CSS bundler (which resolves@import, including fromnode_modules), and produces one stylesheet that is<link>ed into every page's<head>. Because it is a plain global sheet, it styles server-rendered markup and hydrated client islands identically. A customprocesshook lets you swap in Tailwind / PostCSS / Sass. - Theme system — design tokens exposed as CSS custom properties (
--wire-<key>), with built-inlight/darksets, deep-merged user overrides, an SSR<html data-theme>render (no flash), and a tiny client runtime to toggle/persist the choice. - App config — loads
wrnexus.config.ts(theAppConfigtype), applies named profile overrides, and loads the.envcascade.
It runs server-side / at build time. Reach for it when configuring an app, defining themes, or customising how global CSS is produced.
Installation
bun add @wrnexus/styles
Private package — the machine must be authenticated to the
wrnexusnpm org (a read token in~/.npmrc). Requires Bun (Node is not supported).
API
Everything is exported from the package root (@wrnexus/styles).
Config loading
| Export | Signature | Purpose |
|---|---|---|
loadAppConfig |
(appRoot: string, profile?: string) => Promise<AppConfig> |
Load wrnexus.config.* with the active profile deep-merged in (profiles stripped from the result). |
loadRawConfig |
(appRoot: string) => Promise<AppConfig> |
Load the raw config with the profiles map intact; returns {} if no config file exists. |
resolveProfile |
(options?: { explicit?; mode? }) => string |
Resolve the active profile: explicit arg > WRNEXUS_PROFILE env var > mode-based default (production in prod, else development). |
loadEnv |
(appRoot: string, profile: string) => Record<string, string> |
Load the .env cascade for a profile into process.env without clobbering real env vars. Returns what it loaded. |
headToString |
(head?: string | string[]) => string |
Flatten AppConfig.head into a single HTML string. |
Config file names probed, in order: wrnexus.config.ts, wrnexus.config.js, wrnexus.config.mjs.
.env cascade precedence (low → high): .env < .env.<profile> < .env.local < .env.<profile>.local. Variables already present in the real environment always win.
AppConfig
The type of the object your wrnexus.config.ts default-exports. Every field is optional.
| Field | Type | Description |
|---|---|---|
head |
string | string[] |
Raw HTML appended to every page's <head> (e.g. CDN stylesheet/script links). |
seo |
SeoConfig |
Global SEO defaults, merged with each page's exported meta. (from @wrnexus/core) |
security |
SecurityConfig |
Framework security headers and optional CORS policy. (from @wrnexus/core) |
styles |
StylesConfig |
Global stylesheet pipeline config (see below). |
theme |
ThemeConfig |
Design-token themes, deep-merged over the built-in light/dark. |
i18n |
{ default?: string; locales?: string[] } |
Default language + supported locales (strings live in app/locales/*.json). |
db |
{ driver: "sqlite" | "postgres" | "mysql" | "mongo"; url: string } |
Default database connection; reached with getDb(). |
databases |
Record<string, { driver; url }> |
Additional named databases, reached with getDb("<name>"); each has its own app/db/<name>/ migrations/queries. |
realtime |
{ scale?: boolean; redisUrl?: string } |
When scale is true (or redisUrl is set), room broadcasts bridge over Redis pub/sub so they reach clients on every app process. |
port |
number |
Default server port. |
profiles |
Record<string, Partial<Omit<AppConfig, "profiles">>> |
Named profiles (dev, prod, uat, test, …). The active profile's overrides are deep-merged over the base config. Selected via --profile=<name> or WRNEXUS_PROFILE. |
Styles pipeline
| Export | Signature | Purpose |
|---|---|---|
findStyleEntry |
(appDir, appRoot, override?) => string | null |
Resolve the CSS entry: override (relative to appRoot) → app/styles/global.css → an aggregate of all app/styles/*.css (written to app/.wrnexus/styles-entry.css). null if the app has no styles. |
bundleCss |
(entryPath: string, mode: Mode) => Promise<string> |
Bundle an entry with Bun.build (CSS bundler). Resolves @import (local + node_modules), handles nesting, minifies when mode === "production". |
renderStyles |
(ctx: StyleProcessContext, styles?: StylesConfig) => Promise<string> |
Produce final CSS: runs styles.process(ctx) if provided, else bundleCss. Returns "" when ctx.entryPath is null. |
StylesConfig:
interface StylesConfig {
/** CSS entry path relative to the app root. Default: app/styles/global.css */
entry?: string;
/** Custom processor — return the final CSS string (Tailwind/PostCSS/Sass). */
process?: (ctx: StyleProcessContext) => string | Promise<string>;
}
interface StyleProcessContext {
entryPath: string | null; // resolved absolute CSS entry, or null
appDir: string;
appRoot: string;
mode: Mode; // "development" | "production"
}
Theme system
| Export | Type / Signature | Purpose |
|---|---|---|
DEFAULT_THEMES |
Record<string, ThemeTokens> |
Built-in light and dark token maps. |
THEME_COOKIE |
"wire-theme" |
Cookie the resolved theme is read from / persisted to. |
THEME_CSS_HREF |
"/__wrnexus/theme.css" |
URL the generated theme stylesheet is served at. |
THEME_JS_HREF |
"/__wrnexus/theme.js" |
URL the client theme runtime is served at. |
resolveThemeConfig |
(config?: ThemeConfig) => ResolvedTheme |
Deep-merge the user's theme config over the defaults; pick the default theme (config's default if valid, else dark, else the first). |
resolveThemeName |
(cookieValue: string | undefined, theme: ResolvedTheme) => string |
Pick a valid theme name from a cookie, falling back to theme.default. |
renderThemeCss |
(theme: ResolvedTheme) => string |
Generate the theme stylesheet: a :root{…} default plus one [data-theme="<name>"]{…} block per theme. |
renderThemeRuntime |
(theme: ResolvedTheme) => string |
Generate the client runtime (see below). |
Tokens are emitted as --wire-<key> custom properties, except the reserved key color-scheme, which is emitted as the native color-scheme CSS property so form controls and scrollbars match the theme.
ThemeConfig / ThemeTokens / ResolvedTheme:
type ThemeTokens = Record<string, string>;
interface ThemeConfig {
default?: string; // theme used when no cookie is present
themes?: Record<string, ThemeTokens>; // deep-merged over built-in light/dark
}
interface ResolvedTheme {
default: string;
names: string[];
themes: Record<string, ThemeTokens>;
}
Built-in token keys (both light and dark): color-scheme, color-bg, color-surface, color-surface-2, color-text, color-muted, color-border, color-primary, color-primary-hover, color-primary-contrast, color-danger, color-success, color-warning, radius, radius-sm, font-sans, shadow-1.
The client runtime (renderThemeRuntime) exposes window.wireTheme with { get, set, toggle, bind, themes }, wires up any [data-wire-theme-toggle] and [data-wire-theme-set] elements on load, and persists the choice to the wire-theme cookie (max-age 1 year, samesite=lax). toggle() cycles through the configured theme names in order.
Usage
wrnexus.config.ts
import type { AppConfig } from "@wrnexus/styles";
export default {
head: [
'<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap@5/dist/css/bootstrap.min.css">',
],
port: 3000,
db: { driver: "sqlite", url: "app.db" },
theme: {
default: "dark",
themes: {
light: { "color-primary": "#7c3aed" }, // override one token; rest inherited
brand: {
// add a whole new theme
"color-scheme": "dark",
"color-bg": "#0a0a0a",
"color-primary": "#22d3ee",
},
},
},
styles: {
entry: "app/styles/main.css",
},
profiles: {
production: {
db: { driver: "postgres", url: process.env.DATABASE_URL! },
},
},
} satisfies AppConfig;
Loading config + producing CSS
import {
loadAppConfig,
resolveProfile,
loadEnv,
findStyleEntry,
renderStyles,
} from "@wrnexus/styles";
const appRoot = process.cwd();
const mode = "production" as const;
const profile = resolveProfile({ mode });
loadEnv(appRoot, profile);
const config = await loadAppConfig(appRoot, profile);
const appDir = `${appRoot}/app`;
const entryPath = findStyleEntry(appDir, appRoot, config.styles?.entry);
const css = await renderStyles({ entryPath, appDir, appRoot, mode }, config.styles);
Rendering the theme
import {
resolveThemeConfig,
resolveThemeName,
renderThemeCss,
renderThemeRuntime,
THEME_COOKIE,
} from "@wrnexus/styles";
const theme = resolveThemeConfig(config.theme);
// Server: pick the active theme from the request cookie (no flash).
const active = resolveThemeName(cookies[THEME_COOKIE], theme);
// → render <html data-theme={active}>
const themeCss = renderThemeCss(theme); // served at THEME_CSS_HREF
const themeJs = renderThemeRuntime(theme); // served at THEME_JS_HREF
In templates, consume tokens via the custom properties:
.card {
background: var(--wire-color-surface);
color: var(--wire-color-text);
border: 1px solid var(--wire-color-border);
border-radius: var(--wire-radius);
box-shadow: var(--wire-shadow-1);
}
<button data-wire-theme-toggle>Toggle theme</button>
<button data-wire-theme-set="brand">Brand theme</button>
Requirements / Notes
- Bun-only.
bundleCssusesBun.build's CSS bundler for@importresolution, nesting, and minification. Node is not supported. - Config and env loading use
node:fs/node:path/node:urland read fromprocess.env. - Peer package:
@wrnexus/coresupplies theSeoConfigandSecurityConfigtypes referenced byAppConfig. - The bundled global stylesheet, the theme stylesheet (
THEME_CSS_HREF), and the theme runtime (THEME_JS_HREF) are wired into pages by the framework's server; this package only produces their contents.