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;
|
||||
}
|
||||
Reference in New Issue
Block a user