release: WRNexusJS 0.8.0
This commit is contained in:
@@ -1,5 +1,26 @@
|
||||
# @wrnexus/styles
|
||||
|
||||
## Reusable layers and presets
|
||||
|
||||
Compose local or package foundations in order; later layers override earlier
|
||||
ones and the application has final base-config precedence:
|
||||
|
||||
```ts
|
||||
export default defineConfig({
|
||||
extends: ["@workroot/wrnexus-enterprise", "./layers/company"],
|
||||
profiles: { production: { port: 8080 } },
|
||||
});
|
||||
```
|
||||
|
||||
A directory layer exports `wrnexus.layer.ts` (JavaScript/MJS are supported).
|
||||
A package can provide that conventional file or declare
|
||||
`wrnexus.layer` in its `package.json`. Layers may extend other layers and carry
|
||||
the complete app configuration, including plugins that contribute layouts,
|
||||
components, routes, middleware, and migrations. `plugins` and `head` compose;
|
||||
other arrays intentionally replace earlier values. Cycles and missing/invalid
|
||||
entries fail with stable `WRN-CONFIG-LAYER-*` diagnostics. `wrnexus config
|
||||
--explain` lists every resolved layer source.
|
||||
|
||||
> 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.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@wrnexus/styles",
|
||||
"version": "0.7.0",
|
||||
"version": "0.8.0",
|
||||
"type": "module",
|
||||
"main": "src/index.ts",
|
||||
"exports": {
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
export const CURRENT_COMPATIBILITY_DATE = "2026-08-02";
|
||||
export const CURRENT_FRAMEWORK_BEHAVIOUR = 1;
|
||||
|
||||
export interface CompatibilityPolicy {
|
||||
compatibilityDate?: string;
|
||||
frameworkBehaviour?: number;
|
||||
}
|
||||
|
||||
export interface CompatibilityReport {
|
||||
configuredDate?: string;
|
||||
effectiveDate: string;
|
||||
currentDate: string;
|
||||
configuredBehaviour?: number;
|
||||
effectiveBehaviour: number;
|
||||
currentBehaviour: number;
|
||||
needsUpgrade: boolean;
|
||||
future: boolean;
|
||||
messages: string[];
|
||||
}
|
||||
|
||||
export function isCompatibilityDate(value: string): boolean {
|
||||
if (!/^\d{4}-\d{2}-\d{2}$/.test(value)) return false;
|
||||
const date = new Date(`${value}T00:00:00.000Z`);
|
||||
return Number.isFinite(date.getTime()) && date.toISOString().slice(0, 10) === value;
|
||||
}
|
||||
|
||||
export function resolveCompatibility(policy: CompatibilityPolicy): CompatibilityReport {
|
||||
const configuredDate = policy.compatibilityDate;
|
||||
const configuredBehaviour = policy.frameworkBehaviour;
|
||||
const effectiveDate = configuredDate ?? "1970-01-01";
|
||||
const effectiveBehaviour = configuredBehaviour ?? 0;
|
||||
const future =
|
||||
(configuredDate !== undefined && configuredDate > CURRENT_COMPATIBILITY_DATE) ||
|
||||
(configuredBehaviour !== undefined && configuredBehaviour > CURRENT_FRAMEWORK_BEHAVIOUR);
|
||||
const needsUpgrade =
|
||||
!future &&
|
||||
(effectiveDate < CURRENT_COMPATIBILITY_DATE ||
|
||||
effectiveBehaviour < CURRENT_FRAMEWORK_BEHAVIOUR);
|
||||
const messages: string[] = [];
|
||||
if (!configuredDate) messages.push("compatibilityDate is not configured; legacy defaults apply.");
|
||||
if (!configuredBehaviour)
|
||||
messages.push("frameworkBehaviour is not configured; behaviour version 0 applies.");
|
||||
if (future)
|
||||
messages.push("Configuration targets framework behavior newer than this CLI supports.");
|
||||
else if (needsUpgrade)
|
||||
messages.push("A newer compatibility policy is available; review it before upgrading.");
|
||||
else messages.push("Compatibility policy matches the current framework behavior.");
|
||||
return {
|
||||
configuredDate,
|
||||
effectiveDate,
|
||||
currentDate: CURRENT_COMPATIBILITY_DATE,
|
||||
configuredBehaviour,
|
||||
effectiveBehaviour,
|
||||
currentBehaviour: CURRENT_FRAMEWORK_BEHAVIOUR,
|
||||
needsUpgrade,
|
||||
future,
|
||||
messages,
|
||||
};
|
||||
}
|
||||
+203
-10
@@ -8,14 +8,20 @@
|
||||
*/
|
||||
|
||||
import { existsSync, readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { dirname, extname, join, resolve } from "node:path";
|
||||
import { createRequire } from "node:module";
|
||||
import { pathToFileURL } from "node:url";
|
||||
import type { PerformanceBudgets, SecurityConfig, SeoConfig } from "@wrnexus/core";
|
||||
import type { PluginInput } from "@wrnexus/plugin";
|
||||
import type { PluginInput, PluginPermission } from "@wrnexus/plugin";
|
||||
import type { StorageConfig } from "@wrnexus/uploader";
|
||||
import type { ThemeConfig } from "./theme.ts";
|
||||
import type { FontConfig } from "./fonts.ts";
|
||||
import { fontCspSources } from "./fonts.ts";
|
||||
import {
|
||||
isCompatibilityDate,
|
||||
resolveCompatibility,
|
||||
type CompatibilityPolicy,
|
||||
} from "./compatibility.ts";
|
||||
|
||||
export type Mode = "development" | "production";
|
||||
|
||||
@@ -120,6 +126,15 @@ export interface PwaConfig {
|
||||
cacheUrls?: string[];
|
||||
/** Service-worker cache key. Change it to invalidate existing PWA caches. */
|
||||
cacheName?: string;
|
||||
/** Ordered URL rules for runtime caching. Patterns are regular-expression source strings. */
|
||||
runtimeCaching?: Array<{
|
||||
pattern: string;
|
||||
strategy: "network-first" | "cache-first" | "stale-while-revalidate";
|
||||
cacheName?: string;
|
||||
methods?: string[];
|
||||
}>;
|
||||
/** Background Sync tag used by the offline mutation queue. */
|
||||
backgroundSyncTag?: string;
|
||||
}
|
||||
|
||||
export type DevToolbarPosition = "bottom-center" | "bottom-left" | "bottom-right";
|
||||
@@ -229,9 +244,16 @@ export interface CompatibilityConfig {
|
||||
stringLayouts?: boolean;
|
||||
}
|
||||
|
||||
export interface AppConfig {
|
||||
export interface AppConfig extends CompatibilityPolicy {
|
||||
/** Ordered reusable configuration layers; the application always has final precedence. */
|
||||
extends?: string | string[];
|
||||
/** Compiler/dev/build plugins, resolved in deterministic pre/normal/post order. */
|
||||
plugins?: PluginInput;
|
||||
/** Optional least-privilege enforcement for automatically discovered packages. */
|
||||
pluginPermissions?: {
|
||||
enforce?: boolean;
|
||||
grants?: Record<string, PluginPermission[]>;
|
||||
};
|
||||
/** WRN v0.6 explicit import and compatibility resolution. */
|
||||
imports?: ImportsConfig;
|
||||
/** TypeScript-backed .wrn type checking and declaration generation. */
|
||||
@@ -276,7 +298,21 @@ export interface AppConfig {
|
||||
/** 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[] };
|
||||
i18n?: {
|
||||
default?: string;
|
||||
locales?: string[];
|
||||
labels?: Record<string, string>;
|
||||
fallbacks?: Record<string, string[]>;
|
||||
direction?: Record<string, "ltr" | "rtl">;
|
||||
cookie?: {
|
||||
name?: string;
|
||||
maxAge?: number;
|
||||
path?: string;
|
||||
sameSite?: "Strict" | "Lax" | "None";
|
||||
secure?: boolean;
|
||||
};
|
||||
strict?: boolean;
|
||||
};
|
||||
/** Default database connection (driver + url); reached with `getDb()`. */
|
||||
db?: { driver: "sqlite" | "postgres" | "mysql" | "mongo"; url: string };
|
||||
/**
|
||||
@@ -330,15 +366,116 @@ function isPlainObject(value: unknown): value is Record<string, unknown> {
|
||||
}
|
||||
|
||||
/** Deep-merge `override` onto `base` (objects merge; arrays/scalars replace). */
|
||||
function deepMerge<T>(base: T, override: unknown): T {
|
||||
function deepMerge<T>(base: T, override: unknown, path = ""): 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;
|
||||
const currentPath = path ? `${path}.${key}` : key;
|
||||
if (
|
||||
key in out &&
|
||||
["plugins", "head"].includes(currentPath) &&
|
||||
(Array.isArray(out[key]) || Array.isArray(value))
|
||||
) {
|
||||
const before = Array.isArray(out[key]) ? out[key] : out[key] == null ? [] : [out[key]];
|
||||
const after = Array.isArray(value) ? value : value == null ? [] : [value];
|
||||
out[key] = [...before, ...after];
|
||||
} else {
|
||||
out[key] = key in out ? deepMerge(out[key], value, currentPath) : value;
|
||||
}
|
||||
}
|
||||
return out as T;
|
||||
}
|
||||
|
||||
const LAYER_CONFIG_NAMES = [
|
||||
"wrnexus.layer.ts",
|
||||
"wrnexus.layer.js",
|
||||
"wrnexus.layer.mjs",
|
||||
...CONFIG_NAMES,
|
||||
];
|
||||
|
||||
function layerSpecifiers(config: AppConfig): string[] {
|
||||
return config.extends ? (Array.isArray(config.extends) ? config.extends : [config.extends]) : [];
|
||||
}
|
||||
|
||||
function resolveLayerFile(specifier: string, declaringRoot: string, appRoot: string): string {
|
||||
const local =
|
||||
specifier.startsWith(".") || specifier.startsWith("/") || /^[A-Za-z]:[\\/]/.test(specifier);
|
||||
if (local) {
|
||||
const candidate = resolve(declaringRoot, specifier);
|
||||
if (extname(candidate) && existsSync(candidate)) return candidate;
|
||||
for (const name of LAYER_CONFIG_NAMES) {
|
||||
const file = join(candidate, name);
|
||||
if (existsSync(file)) return file;
|
||||
}
|
||||
throw new Error(`WRN-CONFIG-LAYER-NOT-FOUND: ${specifier} from ${declaringRoot}.`);
|
||||
}
|
||||
const require = createRequire(join(appRoot, "package.json"));
|
||||
try {
|
||||
const packageFile = require.resolve(`${specifier}/package.json`);
|
||||
const packageRoot = dirname(packageFile);
|
||||
const pkg = JSON.parse(readFileSync(packageFile, "utf8")) as {
|
||||
wrnexus?: { layer?: string };
|
||||
};
|
||||
if (pkg.wrnexus?.layer) {
|
||||
const file = resolve(packageRoot, pkg.wrnexus.layer);
|
||||
if (existsSync(file)) return file;
|
||||
}
|
||||
for (const name of LAYER_CONFIG_NAMES) {
|
||||
const file = join(packageRoot, name);
|
||||
if (existsSync(file)) return file;
|
||||
}
|
||||
} catch (error) {
|
||||
throw new Error(`WRN-CONFIG-LAYER-NOT-FOUND: package ${specifier} from ${appRoot}.`, {
|
||||
cause: error,
|
||||
});
|
||||
}
|
||||
throw new Error(`WRN-CONFIG-LAYER-ENTRY: ${specifier} has no wrnexus layer entry.`);
|
||||
}
|
||||
|
||||
async function importConfigFile(file: string): Promise<AppConfig> {
|
||||
const mod = (await import(pathToFileURL(file).href)) as { default?: AppConfig };
|
||||
if (!mod.default || !isPlainObject(mod.default)) {
|
||||
throw new Error(`WRN-CONFIG-LAYER-SHAPE: ${file} must export a configuration object.`);
|
||||
}
|
||||
return mod.default;
|
||||
}
|
||||
|
||||
export interface ResolvedConfigLayers {
|
||||
config: AppConfig;
|
||||
sources: string[];
|
||||
}
|
||||
|
||||
export async function resolveConfigLayers(
|
||||
appRoot: string,
|
||||
application: AppConfig,
|
||||
): Promise<ResolvedConfigLayers> {
|
||||
const sources: string[] = [];
|
||||
const visiting: string[] = [];
|
||||
const resolvedFiles = new Set<string>();
|
||||
const visit = async (config: AppConfig, declaringRoot: string): Promise<AppConfig> => {
|
||||
let merged: AppConfig = {};
|
||||
for (const specifier of layerSpecifiers(config)) {
|
||||
const file = resolveLayerFile(specifier, declaringRoot, appRoot);
|
||||
if (visiting.includes(file)) {
|
||||
throw new Error(`WRN-CONFIG-LAYER-CYCLE: ${[...visiting, file].join(" -> ")}`);
|
||||
}
|
||||
visiting.push(file);
|
||||
const layer = await importConfigFile(file);
|
||||
const resolvedLayer = await visit(layer, dirname(file));
|
||||
visiting.pop();
|
||||
merged = deepMerge(merged, resolvedLayer);
|
||||
if (!resolvedFiles.has(file)) {
|
||||
resolvedFiles.add(file);
|
||||
sources.push(file);
|
||||
}
|
||||
}
|
||||
const own = { ...config };
|
||||
delete own.extends;
|
||||
return deepMerge(merged, own);
|
||||
};
|
||||
return { config: await visit(application, appRoot), sources };
|
||||
}
|
||||
|
||||
/** 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) {
|
||||
@@ -358,10 +495,12 @@ export async function loadAppConfig(appRoot: string, profile?: string): Promise<
|
||||
// module evaluation. Load the profile cascade before importing the module so
|
||||
// development and production behave consistently.
|
||||
loadEnv(appRoot, active);
|
||||
const base = await loadRawConfig(appRoot);
|
||||
const raw = await loadRawConfig(appRoot);
|
||||
const { config: base } = await resolveConfigLayers(appRoot, raw);
|
||||
const override = base.profiles?.[active];
|
||||
const merged: AppConfig = override ? deepMerge(base, override) : { ...base };
|
||||
delete merged.profiles;
|
||||
delete merged.extends;
|
||||
applyFontCsp(merged);
|
||||
const issues = validateAppConfig(merged);
|
||||
const errors = issues.filter((issue) => issue.severity === "error");
|
||||
@@ -451,14 +590,66 @@ export function defineConfig(config: AppConfig): AppConfig {
|
||||
|
||||
export function validateAppConfig(config: AppConfig): ConfigIssue[] {
|
||||
const issues: ConfigIssue[] = [];
|
||||
if (config.compatibilityDate !== undefined && !isCompatibilityDate(config.compatibilityDate)) {
|
||||
issues.push({
|
||||
path: "compatibilityDate",
|
||||
severity: "error",
|
||||
message: "must be a real ISO calendar date in YYYY-MM-DD format",
|
||||
});
|
||||
}
|
||||
if (
|
||||
config.frameworkBehaviour !== undefined &&
|
||||
(!Number.isInteger(config.frameworkBehaviour) || config.frameworkBehaviour < 1)
|
||||
) {
|
||||
issues.push({
|
||||
path: "frameworkBehaviour",
|
||||
severity: "error",
|
||||
message: "must be a positive integer",
|
||||
});
|
||||
}
|
||||
const compatibility = resolveCompatibility(config);
|
||||
if (compatibility.future) {
|
||||
issues.push({
|
||||
path: "compatibilityDate",
|
||||
severity: "error",
|
||||
message: "targets framework behavior newer than this version supports",
|
||||
});
|
||||
}
|
||||
const sampleRate = config.observability?.sampleRate;
|
||||
if (sampleRate !== undefined && (sampleRate < 0 || sampleRate > 1)) {
|
||||
if (
|
||||
sampleRate !== undefined &&
|
||||
(!Number.isFinite(sampleRate) || sampleRate < 0 || sampleRate > 1)
|
||||
) {
|
||||
issues.push({
|
||||
path: "observability.sampleRate",
|
||||
severity: "error",
|
||||
message: "must be between 0 and 1",
|
||||
});
|
||||
}
|
||||
if (config.observability?.exporter === "otlp") {
|
||||
const endpoint = config.observability.endpoint;
|
||||
const validEndpoint = (() => {
|
||||
try {
|
||||
return Boolean(endpoint && ["http:", "https:"].includes(new URL(endpoint).protocol));
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
})();
|
||||
if (!validEndpoint) {
|
||||
issues.push({
|
||||
path: "observability.endpoint",
|
||||
severity: "error",
|
||||
message: "must be an absolute HTTP(S) URL when exporter is otlp",
|
||||
});
|
||||
}
|
||||
}
|
||||
if (config.observability?.serviceName !== undefined && !config.observability.serviceName.trim()) {
|
||||
issues.push({
|
||||
path: "observability.serviceName",
|
||||
severity: "error",
|
||||
message: "must not be empty",
|
||||
});
|
||||
}
|
||||
const budgets = config.performance?.budgets;
|
||||
if (budgets) {
|
||||
for (const [name, value] of Object.entries(budgets)) {
|
||||
@@ -494,7 +685,9 @@ export async function explainAppConfig(
|
||||
): Promise<ExplainedConfig> {
|
||||
const active = profile ?? resolveProfile();
|
||||
const config = await loadAppConfig(appRoot, active);
|
||||
const sources = CONFIG_NAMES.filter((name) => existsSync(join(appRoot, name)));
|
||||
const appSources = CONFIG_NAMES.filter((name) => existsSync(join(appRoot, name)));
|
||||
const raw = await loadRawConfig(appRoot);
|
||||
const layerSources = (await resolveConfigLayers(appRoot, raw)).sources;
|
||||
const envSources = [".env", ".env.local", `.env.${active}`, `.env.${active}.local`].filter(
|
||||
(name) => existsSync(join(appRoot, name)),
|
||||
);
|
||||
@@ -502,7 +695,7 @@ export async function explainAppConfig(
|
||||
profile: active,
|
||||
config,
|
||||
issues: validateAppConfig(config),
|
||||
sources: [...sources, ...envSources],
|
||||
sources: [...layerSources, ...appSources, ...envSources],
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -23,6 +23,7 @@ export type {
|
||||
StylesConfig,
|
||||
StyleProcessContext,
|
||||
Mode,
|
||||
ResolvedConfigLayers,
|
||||
} from "./config.ts";
|
||||
export {
|
||||
defineConfig,
|
||||
@@ -32,8 +33,16 @@ export {
|
||||
loadEnv,
|
||||
loadRawConfig,
|
||||
resolveProfile,
|
||||
resolveConfigLayers,
|
||||
validateAppConfig,
|
||||
} from "./config.ts";
|
||||
export {
|
||||
CURRENT_COMPATIBILITY_DATE,
|
||||
CURRENT_FRAMEWORK_BEHAVIOUR,
|
||||
isCompatibilityDate,
|
||||
resolveCompatibility,
|
||||
} from "./compatibility.ts";
|
||||
export type { CompatibilityPolicy, CompatibilityReport } from "./compatibility.ts";
|
||||
export { findStyleEntry, bundleCss } from "./styles.ts";
|
||||
export type { FontConfig, GoogleFont, LocalFontFace, FontDisplay } from "./fonts.ts";
|
||||
export { renderFontHead, renderProductionFontHead, fontCspSources } from "./fonts.ts";
|
||||
@@ -43,6 +52,8 @@ export type {
|
||||
ResolvedTheme,
|
||||
ThemePaletteName,
|
||||
CustomThemePalette,
|
||||
ThemeToken,
|
||||
ThemeSemanticColor,
|
||||
} from "./theme.ts";
|
||||
export {
|
||||
DEFAULT_THEMES,
|
||||
@@ -55,6 +66,8 @@ export {
|
||||
resolveThemeName,
|
||||
renderThemeCss,
|
||||
renderThemeRuntime,
|
||||
defineThemeTokens,
|
||||
themeVar,
|
||||
} from "./theme.ts";
|
||||
|
||||
import type { Mode, StyleProcessContext, StylesConfig } from "./config.ts";
|
||||
|
||||
@@ -11,7 +11,52 @@
|
||||
* emitted as the native CSS property instead of a custom property.
|
||||
*/
|
||||
|
||||
export type ThemeTokens = Record<string, string>;
|
||||
export type ThemeSemanticColor =
|
||||
"primary" | "secondary" | "info" | "success" | "warning" | "danger" | "error";
|
||||
export type ThemeToken =
|
||||
| "color-scheme"
|
||||
| "color-bg"
|
||||
| "color-background"
|
||||
| "color-foreground"
|
||||
| "color-surface"
|
||||
| "color-surface-2"
|
||||
| "color-surface-raised"
|
||||
| "color-surface-muted"
|
||||
| "color-text"
|
||||
| "color-text-muted"
|
||||
| "color-text-subtle"
|
||||
| "color-muted"
|
||||
| "color-border"
|
||||
| "color-border-strong"
|
||||
| "color-code-background"
|
||||
| "color-code-surface"
|
||||
| "color-code-text"
|
||||
| "color-code-muted"
|
||||
| "color-code-border"
|
||||
| `color-${ThemeSemanticColor}`
|
||||
| `color-${ThemeSemanticColor}-${"hover" | "active" | "contrast" | "soft" | "muted" | "text"}`
|
||||
| `color-on-${"primary" | "secondary"}`
|
||||
| "radius"
|
||||
| "radius-sm"
|
||||
| "shadow-1"
|
||||
| "shadow-sm"
|
||||
| "shadow-md"
|
||||
| "shadow-lg"
|
||||
| "space-section"
|
||||
| "space-section-sm"
|
||||
| "container-max"
|
||||
| "font-sans";
|
||||
|
||||
/** Known tokens get autocomplete while applications may add namespaced custom tokens. */
|
||||
export type ThemeTokens = Partial<Record<ThemeToken, string>> & Record<string, string>;
|
||||
|
||||
export function defineThemeTokens<T extends ThemeTokens>(tokens: T): T {
|
||||
return tokens;
|
||||
}
|
||||
|
||||
export function themeVar(token: ThemeToken, fallback?: string): string {
|
||||
return `var(--wire-${token}${fallback ? `, ${fallback}` : ""})`;
|
||||
}
|
||||
|
||||
export const THEME_PALETTE_NAMES = [
|
||||
"blue",
|
||||
|
||||
@@ -2,12 +2,50 @@ import { test, expect, afterEach } from "bun:test";
|
||||
import { mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { loadAppConfig, resolveProfile, loadEnv, renderStyles } from "../src/index.ts";
|
||||
import {
|
||||
loadAppConfig,
|
||||
resolveProfile,
|
||||
loadEnv,
|
||||
renderStyles,
|
||||
resolveCompatibility,
|
||||
validateAppConfig,
|
||||
explainAppConfig,
|
||||
} from "../src/index.ts";
|
||||
|
||||
afterEach(() => {
|
||||
delete process.env.WRNEXUS_PROFILE;
|
||||
});
|
||||
|
||||
test("compatibility dates pin behavior and reject invalid or future policies", () => {
|
||||
expect(
|
||||
resolveCompatibility({ compatibilityDate: "2026-08-02", frameworkBehaviour: 1 }),
|
||||
).toMatchObject({ needsUpgrade: false, future: false, effectiveBehaviour: 1 });
|
||||
expect(validateAppConfig({ compatibilityDate: "2026-02-31" })).toContainEqual(
|
||||
expect.objectContaining({ path: "compatibilityDate", severity: "error" }),
|
||||
);
|
||||
expect(validateAppConfig({ frameworkBehaviour: 2 })).toContainEqual(
|
||||
expect.objectContaining({ severity: "error" }),
|
||||
);
|
||||
});
|
||||
|
||||
test("observability config requires bounded sampling and a valid OTLP endpoint", () => {
|
||||
expect(validateAppConfig({ observability: { sampleRate: Number.NaN } })).toContainEqual(
|
||||
expect.objectContaining({ path: "observability.sampleRate", severity: "error" }),
|
||||
);
|
||||
expect(
|
||||
validateAppConfig({ observability: { exporter: "otlp", endpoint: "collector:4318" } }),
|
||||
).toContainEqual(expect.objectContaining({ path: "observability.endpoint", severity: "error" }));
|
||||
expect(
|
||||
validateAppConfig({
|
||||
observability: {
|
||||
exporter: "otlp",
|
||||
endpoint: "https://collector.example/v1/traces",
|
||||
serviceName: "api",
|
||||
},
|
||||
}),
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
test("resolveProfile: explicit > WRNEXUS_PROFILE > mode default", () => {
|
||||
delete process.env.WRNEXUS_PROFILE;
|
||||
expect(resolveProfile({ mode: "development" })).toBe("development");
|
||||
@@ -47,6 +85,77 @@ test("loadAppConfig deep-merges the active profile and strips `profiles`", async
|
||||
expect(uat.db!.driver).toBe("sqlite");
|
||||
});
|
||||
|
||||
test("config layers compose recursively with deterministic application precedence", async () => {
|
||||
const root = mkdtempSync(join(tmpdir(), "wrnexus-layers-"));
|
||||
try {
|
||||
mkdirSync(join(root, "layers", "base"), { recursive: true });
|
||||
mkdirSync(join(root, "layers", "company"), { recursive: true });
|
||||
writeFileSync(
|
||||
join(root, "layers", "base", "wrnexus.layer.mjs"),
|
||||
`export default {
|
||||
port: 1000, head: ["<meta name='base'>"], seo: { siteName: "Foundation", title: "Base" },
|
||||
profiles: { production: { port: 7000 } }
|
||||
}`,
|
||||
);
|
||||
writeFileSync(
|
||||
join(root, "layers", "company", "wrnexus.layer.mjs"),
|
||||
`export default {
|
||||
extends: ["../base"], head: "<meta name='company'>", seo: { title: "Company" }
|
||||
}`,
|
||||
);
|
||||
writeFileSync(
|
||||
join(root, "wrnexus.config.mjs"),
|
||||
`export default {
|
||||
extends: ["./layers/company"], port: 3000, head: ["<meta name='app'>"], seo: { title: "App" },
|
||||
profiles: { production: { port: 8000 } }
|
||||
}`,
|
||||
);
|
||||
|
||||
const config = await loadAppConfig(root, "production");
|
||||
expect(config.port).toBe(8000);
|
||||
expect((await loadAppConfig(root, "development")).port).toBe(3000);
|
||||
expect(config.seo).toEqual({ siteName: "Foundation", title: "App" });
|
||||
expect(config.head).toEqual([
|
||||
"<meta name='base'>",
|
||||
"<meta name='company'>",
|
||||
"<meta name='app'>",
|
||||
]);
|
||||
expect(config.extends).toBeUndefined();
|
||||
const explained = await explainAppConfig(root, "production");
|
||||
expect(explained.sources.filter((source) => source.includes("wrnexus.layer")).length).toBe(2);
|
||||
} finally {
|
||||
rmSync(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("config layers resolve packages and reject dependency cycles", async () => {
|
||||
const root = mkdtempSync(join(tmpdir(), "wrnexus-package-layer-"));
|
||||
try {
|
||||
const pkg = join(root, "node_modules", "company-layer");
|
||||
mkdirSync(pkg, { recursive: true });
|
||||
writeFileSync(join(root, "package.json"), JSON.stringify({ name: "app" }));
|
||||
writeFileSync(
|
||||
join(pkg, "package.json"),
|
||||
JSON.stringify({
|
||||
name: "company-layer",
|
||||
wrnexus: { layer: "./foundation.mjs" },
|
||||
}),
|
||||
);
|
||||
writeFileSync(join(pkg, "foundation.mjs"), `export default { port: 4400 }`);
|
||||
writeFileSync(join(root, "wrnexus.config.mjs"), `export default { extends: "company-layer" }`);
|
||||
expect((await loadAppConfig(root)).port).toBe(4400);
|
||||
|
||||
const cycle = join(root, "cycle");
|
||||
mkdirSync(cycle);
|
||||
writeFileSync(join(cycle, "a.mjs"), `export default { extends: "./b.mjs" }`);
|
||||
writeFileSync(join(cycle, "b.mjs"), `export default { extends: "./a.mjs" }`);
|
||||
writeFileSync(join(cycle, "wrnexus.config.mjs"), `export default { extends: "./a.mjs" }`);
|
||||
await expect(loadAppConfig(cycle)).rejects.toThrow("WRN-CONFIG-LAYER-CYCLE");
|
||||
} finally {
|
||||
rmSync(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
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');
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
import { expect, test } from "bun:test";
|
||||
import { defineThemeTokens, themeVar } from "../src/index.ts";
|
||||
|
||||
test("typed theme token helpers preserve custom tokens and emit CSS variables", () => {
|
||||
const tokens = defineThemeTokens({
|
||||
"color-primary": "#2563eb",
|
||||
"space-product-card": "1rem",
|
||||
});
|
||||
expect(tokens["space-product-card"]).toBe("1rem");
|
||||
expect(themeVar("color-primary")).toBe("var(--wire-color-primary)");
|
||||
expect(themeVar("color-text", "#111")).toBe("var(--wire-color-text, #111)");
|
||||
});
|
||||
Reference in New Issue
Block a user