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
+227
View File
@@ -0,0 +1,227 @@
# @wrnexus/styles
> Global CSS bundling, the `--wire-*` design-token theme system, and the `wrnexus.config.ts` app-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:
1. **Global stylesheet pipeline** — finds `app/styles/global.css` (or aggregates `app/styles/*.css`), bundles it with Bun's CSS bundler (which resolves `@import`, including from `node_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 custom `process` hook lets you swap in Tailwind / PostCSS / Sass.
2. **Theme system** — design tokens exposed as CSS custom properties (`--wire-<key>`), with built-in `light`/`dark` sets, deep-merged user overrides, an SSR `<html data-theme>` render (no flash), and a tiny client runtime to toggle/persist the choice.
3. **App config** — loads `wrnexus.config.ts` (the `AppConfig` type), applies named profile overrides, and loads the `.env` cascade.
It runs server-side / at build time. Reach for it when configuring an app, defining themes, or customising how global CSS is produced.
## Installation
```bash
bun add @wrnexus/styles
```
> Private package — the machine must be authenticated to the `wrnexus` npm 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`:
```ts
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`:
```ts
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`
```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
```ts
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
```ts
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:
```css
.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);
}
```
```html
<button data-wire-theme-toggle>Toggle theme</button>
<button data-wire-theme-set="brand">Brand theme</button>
```
## Requirements / Notes
- **Bun-only.** `bundleCss` uses `Bun.build`'s CSS bundler for `@import` resolution, nesting, and minification. Node is not supported.
- Config and env loading use `node:fs` / `node:path` / `node:url` and read from `process.env`.
- Peer package: `@wrnexus/core` supplies the `SeoConfig` and `SecurityConfig` types referenced by `AppConfig`.
- 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.
+12
View File
@@ -0,0 +1,12 @@
{
"name": "@wrnexus/styles",
"version": "0.2.12",
"type": "module",
"main": "src/index.ts",
"exports": {
".": "./src/index.ts"
},
"dependencies": {
"@wrnexus/uploader": "workspace:*"
}
}
+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);
})();
`;
}
+68
View File
@@ -0,0 +1,68 @@
import { test, expect, afterEach } from "bun:test";
import { mkdtempSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { loadAppConfig, resolveProfile, loadEnv } from "../src/index.ts";
afterEach(() => {
delete process.env.WRNEXUS_PROFILE;
});
test("resolveProfile: explicit > WRNEXUS_PROFILE > mode default", () => {
delete process.env.WRNEXUS_PROFILE;
expect(resolveProfile({ mode: "development" })).toBe("development");
expect(resolveProfile({ mode: "production" })).toBe("production");
expect(resolveProfile({ explicit: "uat", mode: "production" })).toBe("uat");
process.env.WRNEXUS_PROFILE = "test";
expect(resolveProfile({ mode: "production" })).toBe("test");
expect(resolveProfile({ explicit: "uat" })).toBe("uat"); // explicit still wins
});
test("loadAppConfig deep-merges the active profile and strips `profiles`", async () => {
const dir = mkdtempSync(join(tmpdir(), "wire-cfg-"));
writeFileSync(
join(dir, "wrnexus.config.mjs"),
`export default {
port: 3000,
db: { driver: "sqlite", url: "file:./dev.db" },
seo: { title: "Base", siteName: "App" },
profiles: {
prod: { db: { driver: "postgres", url: "postgres://prod" }, port: 8080 },
uat: { seo: { title: "UAT" } },
},
};`,
);
const dev = await loadAppConfig(dir, "development");
expect(dev.db).toEqual({ driver: "sqlite", url: "file:./dev.db" });
expect((dev as { profiles?: unknown }).profiles).toBeUndefined();
const prod = await loadAppConfig(dir, "prod");
expect(prod.db).toEqual({ driver: "postgres", url: "postgres://prod" });
expect(prod.port).toBe(8080);
expect(prod.seo).toEqual({ title: "Base", siteName: "App" }); // untouched
const uat = await loadAppConfig(dir, "uat");
expect(uat.seo).toEqual({ title: "UAT", siteName: "App" }); // deep-merged
expect(uat.db!.driver).toBe("sqlite");
});
test("loadEnv layers .env files by precedence; real env always wins", () => {
const dir = mkdtempSync(join(tmpdir(), "wire-env-"));
writeFileSync(join(dir, ".env"), 'BASE=1\nSHARED=base\n# a comment\nQUOTED="hi there"\n');
writeFileSync(join(dir, ".env.prod"), "SHARED=prod\nPROD_ONLY=yes\nREALVAR=fromfile\n");
writeFileSync(join(dir, ".env.prod.local"), "SHARED=local\n");
process.env.REALVAR = "real"; // simulate a real environment variable
try {
const loaded = loadEnv(dir, "prod");
expect(loaded.BASE).toBe("1");
expect(process.env.BASE).toBe("1");
expect(process.env.QUOTED).toBe("hi there"); // quotes stripped
expect(process.env.SHARED).toBe("local"); // .env.prod.local beats .env.prod beats .env
expect(process.env.PROD_ONLY).toBe("yes");
expect(process.env.REALVAR).toBe("real"); // real env NOT overridden by a file
} finally {
for (const k of ["BASE", "SHARED", "PROD_ONLY", "QUOTED", "REALVAR"]) delete process.env[k];
}
});
+22
View File
@@ -0,0 +1,22 @@
import { expect, test } from "bun:test";
import { fontCspSources, renderFontHead } from "../src/index.ts";
test("renders subsetted Google and escaped local font markup", () => {
const html = renderFontHead({
google: [{ family: "Inter", weights: [700, 400] }],
local: [
{
family: 'Bad"</style><script>x</script>',
src: '/font".woff2',
preload: true,
},
],
});
expect(html).toContain("family=Inter:wght@400;700");
expect(html).not.toContain("</style><script>");
expect(html).toContain("&quot;");
expect(fontCspSources({ google: [{ family: "Inter" }] })).toEqual({
style: ["https://fonts.googleapis.com"],
font: ["https://fonts.gstatic.com"],
});
});
+42
View File
@@ -0,0 +1,42 @@
import { test, expect } from "bun:test";
import {
resolveThemeConfig,
resolveThemeName,
renderThemeCss,
renderThemeRuntime,
} from "../src/index.ts";
test("resolveThemeConfig merges user tokens over built-in light/dark", () => {
const t = resolveThemeConfig({ default: "dark", themes: { dark: { "color-primary": "#abc" } } });
expect(t.default).toBe("dark");
expect(t.names).toContain("light");
expect(t.names).toContain("dark");
expect(t.themes.dark["color-primary"]).toBe("#abc"); // overridden
expect(t.themes.dark["color-bg"]).toBeDefined(); // built-in kept
});
test("resolveThemeName validates against configured names", () => {
const t = resolveThemeConfig();
expect(resolveThemeName("light", t)).toBe("light");
expect(resolveThemeName("nonsense", t)).toBe(t.default);
expect(resolveThemeName(undefined, t)).toBe(t.default);
});
test("renderThemeCss emits :root + per-theme blocks and --wire-* vars", () => {
const t = resolveThemeConfig({
default: "dark",
themes: { dark: { "color-primary": "#6c8cff" } },
});
const css = renderThemeCss(t);
expect(css).toContain(":root{");
expect(css).toContain('[data-theme="dark"]{');
expect(css).toContain("--wire-color-primary:#6c8cff");
expect(css).toContain("color-scheme:dark"); // reserved token → native property
});
test("renderThemeRuntime bakes the theme names for cycling", () => {
const t = resolveThemeConfig();
const js = renderThemeRuntime(t);
expect(js).toContain("data-wire-theme-toggle");
expect(js).toContain(JSON.stringify(t.names));
});