release: WRNexusJS 0.8.0
This commit is contained in:
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user