first commit
This commit is contained in:
@@ -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);
|
||||
})();
|
||||
`;
|
||||
}
|
||||
Reference in New Issue
Block a user