first commit
This commit is contained in:
@@ -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.
|
||||
@@ -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:*"
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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, "&")
|
||||
.replace(/"/g, """)
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">");
|
||||
}
|
||||
|
||||
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: [] };
|
||||
}
|
||||
@@ -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 };
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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);
|
||||
})();
|
||||
`;
|
||||
}
|
||||
@@ -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];
|
||||
}
|
||||
});
|
||||
@@ -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(""");
|
||||
expect(fontCspSources({ google: [{ family: "Inter" }] })).toEqual({
|
||||
style: ["https://fonts.googleapis.com"],
|
||||
font: ["https://fonts.gstatic.com"],
|
||||
});
|
||||
});
|
||||
@@ -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));
|
||||
});
|
||||
Reference in New Issue
Block a user