release: WRNexusJS 0.4.0

This commit is contained in:
2026-07-27 12:42:18 +05:30
parent 8b728a3e5d
commit 30e5721e84
250 changed files with 10065 additions and 3923 deletions
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@wrnexus/styles",
"version": "0.3.6",
"version": "0.4.0",
"type": "module",
"main": "src/index.ts",
"exports": {
+80
View File
@@ -0,0 +1,80 @@
export interface CssTokenAudit {
declared: string[];
used: string[];
missing: string[];
unused: string[];
}
/** Audit framework design-token declarations and var() references. */
export function auditWireTokens(css: string): CssTokenAudit {
const declared = new Set<string>();
const used = new Set<string>();
for (const match of css.matchAll(/(--wire-[A-Za-z0-9_-]+)\s*:/g)) declared.add(match[1]!);
for (const match of css.matchAll(/var\(\s*(--wire-[A-Za-z0-9_-]+)/g)) used.add(match[1]!);
return {
declared: [...declared].sort(),
used: [...used].sort(),
missing: [...used].filter((token) => !declared.has(token)).sort(),
unused: [...declared].filter((token) => !used.has(token)).sort(),
};
}
export interface StyleSource {
path: string;
reason?: string;
}
/** Normalize/dedupe Tailwind scan sources without allowing line injection. */
export function normalizeStyleSources(values: readonly (string | StyleSource)[]): StyleSource[] {
const result = new Map<string, StyleSource>();
for (const value of values) {
const source = typeof value === "string" ? { path: value } : value;
const path = source.path.trim();
if (!path || /[\r\n]/.test(path)) continue;
result.set(path, { path, reason: source.reason });
}
return [...result.values()];
}
export function tailwindSourceDirectives(values: readonly (string | StyleSource)[]): string {
return normalizeStyleSources(values)
.map(({ path }) => `@source ${JSON.stringify(path)};`)
.join("\n");
}
export interface ContrastResult {
ratio: number;
level: "fail" | "aa-large" | "aa" | "aaa";
}
function hexRgb(value: string): [number, number, number] | null {
const hex = value.trim().replace(/^#/, "");
const normalized = hex.length === 3 ? [...hex].map((part) => part + part).join("") : hex;
if (!/^[0-9a-f]{6}$/i.test(normalized)) return null;
return [0, 2, 4].map((offset) => Number.parseInt(normalized.slice(offset, offset + 2), 16)) as [
number,
number,
number,
];
}
function luminance(value: string): number | null {
const rgb = hexRgb(value);
if (!rgb) return null;
const channels = rgb.map((channel) => {
const scaled = channel / 255;
return scaled <= 0.03928 ? scaled / 12.92 : ((scaled + 0.055) / 1.055) ** 2.4;
});
return 0.2126 * channels[0]! + 0.7152 * channels[1]! + 0.0722 * channels[2]!;
}
export function contrast(foreground: string, background: string): ContrastResult | null {
const left = luminance(foreground);
const right = luminance(background);
if (left === null || right === null) return null;
const ratio = (Math.max(left, right) + 0.05) / (Math.min(left, right) + 0.05);
return {
ratio: Math.round(ratio * 100) / 100,
level: ratio >= 7 ? "aaa" : ratio >= 4.5 ? "aa" : ratio >= 3 ? "aa-large" : "fail",
};
}
+10
View File
@@ -22,6 +22,12 @@ export type Mode = "development" | "production";
export interface StyleProcessContext {
/** Resolved absolute path to the CSS entry, or null if there is none. */
entryPath: string | null;
/** Original application entry when a package-aware wrapper was generated. */
originalEntryPath?: string | null;
/** Package component/style directories that processors should scan. */
sources?: string[];
/** Package-owned CSS entries automatically imported into the application bundle. */
entries?: string[];
appDir: string;
appRoot: string;
mode: Mode;
@@ -36,6 +42,10 @@ export interface StylesConfig {
* used (which already resolves `@import`, including from node_modules).
*/
process?: (ctx: StyleProcessContext) => string | Promise<string>;
/** Automatically append package scan sources to custom processor input. Default true. */
includePackageSources?: boolean;
/** Production defaults to throw; development defaults to best-effort fallback. */
failureMode?: "throw" | "fallback";
}
export interface MobileConfig {
+58 -13
View File
@@ -58,35 +58,40 @@ export {
import type { Mode, StyleProcessContext, StylesConfig } from "./config.ts";
import { bundleCss } from "./styles.ts";
import { mkdir, writeFile } from "node:fs/promises";
import { isAbsolute, join, relative } from "node:path";
/**
* 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.
* Development can fall back to best-effort CSS. Production throws by default so
* a deployment cannot silently ship unprocessed Tailwind/PostCSS directives.
*/
export async function renderStyles(
ctx: StyleProcessContext,
styles?: StylesConfig,
): Promise<string> {
if (!ctx.entryPath) return "";
if (!ctx.entryPath && !ctx.entries?.length) return "";
const processContext =
styles?.includePackageSources === false ? ctx : await packageAwareStyleContext(ctx);
if (!processContext.entryPath) return "";
if (styles?.process) {
try {
return String(await styles.process(ctx));
return String(await styles.process(processContext));
} 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);
const details =
`[wrnexus] Stylesheet processing failed.\n` +
` ${msg.split("\n")[0]}\n` +
" If you use Tailwind, run `bun install` in the application workspace so the CLI and plugins are available.";
const failureMode = styles.failureMode ?? (ctx.mode === "production" ? "throw" : "fallback");
if (failureMode === "throw") throw new Error(details, { cause: err });
console.error(`\n${details}\n Serving best-effort unprocessed CSS in development.\n`);
return await fallbackCss(processContext);
}
}
return bundleCss(ctx.entryPath, ctx.mode);
return bundleCss(processContext.entryPath!, processContext.mode);
}
/**
@@ -107,4 +112,44 @@ async function fallbackCss(ctx: StyleProcessContext): Promise<string> {
}
}
async function packageAwareStyleContext(ctx: StyleProcessContext): Promise<StyleProcessContext> {
const sources = [...new Set((ctx.sources ?? []).filter(Boolean))];
const entries = [...new Set((ctx.entries ?? []).filter(Boolean))];
if (sources.length === 0 && entries.length === 0) return ctx;
if (!ctx.entryPath && entries.length === 0) return ctx;
const outputDir = join(ctx.appRoot, ".wrnexus", "styles");
const wrapper = join(outputDir, "global.with-package-sources.css");
await mkdir(outputDir, { recursive: true });
const localPath = (value: string): string => {
const path = isAbsolute(value) ? relative(outputDir, value) : value;
const normalized = path.replace(/\\/g, "/");
return normalized.startsWith(".") ? normalized : `./${normalized}`;
};
const quote = (value: string) => localPath(value).replace(/"/g, '\\"');
const imports = [...(ctx.entryPath ? [ctx.entryPath] : []), ...entries]
.map((value) => `@import "${quote(value)}";`)
.join("\n");
const directives = sources.map((value) => `@source "${quote(value)}";`).join("\n");
await writeFile(
wrapper,
`${imports}\n\n/* WRNexus package scan sources */\n${directives}\n`,
"utf8",
);
return {
...ctx,
originalEntryPath: ctx.entryPath,
entryPath: wrapper,
sources,
entries,
};
}
export type { Mode as StylesMode };
export {
auditWireTokens,
normalizeStyleSources,
tailwindSourceDirectives,
contrast,
} from "./audit.ts";
export type { CssTokenAudit, StyleSource, ContrastResult } from "./audit.ts";
+4 -2
View File
@@ -62,6 +62,8 @@ export async function bundleCss(entryPath: string, mode: Mode): Promise<string>
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();
const cssOutput =
result.outputs.find((output) => output.path?.endsWith(".css")) ?? result.outputs[0];
if (!cssOutput) throw new Error("CSS bundle produced no output.");
return await cssOutput.text();
}
+93 -2
View File
@@ -1,8 +1,8 @@
import { test, expect, afterEach } from "bun:test";
import { mkdtempSync, writeFileSync } from "node:fs";
import { mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { loadAppConfig, resolveProfile, loadEnv } from "../src/index.ts";
import { loadAppConfig, resolveProfile, loadEnv, renderStyles } from "../src/index.ts";
afterEach(() => {
delete process.env.WRNEXUS_PROFILE;
@@ -66,3 +66,94 @@ test("loadEnv layers .env files by precedence; real env always wins", () => {
for (const k of ["BASE", "SHARED", "PROD_ONLY", "QUOTED", "REALVAR"]) delete process.env[k];
}
});
test("package-aware styles add relative imports and Tailwind scan sources", async () => {
const root = mkdtempSync(join(tmpdir(), "wrnexus-styles-"));
try {
const appDir = join(root, "app");
const entry = join(appDir, "styles", "global.css");
const packageSource = join(root, "packages", "system", "components");
const packageCss = join(root, "packages", "system", "system.css");
mkdirSync(join(appDir, "styles"), { recursive: true });
mkdirSync(packageSource, { recursive: true });
writeFileSync(entry, '@import "tailwindcss";\n');
writeFileSync(packageCss, ".system{}\n");
let wrapper = "";
const css = await renderStyles(
{
entryPath: entry,
appDir,
appRoot: root,
mode: "development",
sources: [packageSource],
entries: [packageCss],
},
{
process: async ({ entryPath, originalEntryPath, sources }) => {
expect(originalEntryPath).toBe(entry);
expect(sources).toEqual([packageSource]);
wrapper = readFileSync(entryPath!, "utf8");
return ".compiled{}";
},
},
);
expect(css).toBe(".compiled{}");
expect(wrapper).toContain('@import "../../app/styles/global.css";');
expect(wrapper).toContain('@source "../../packages/system/components";');
expect(wrapper).not.toContain(root.replace(/\\/g, "/"));
} finally {
rmSync(root, { recursive: true, force: true });
}
});
test("production style processors fail closed by default", async () => {
const root = mkdtempSync(join(tmpdir(), "wrnexus-styles-"));
try {
const entry = join(root, "global.css");
writeFileSync(entry, "body{}\n");
await expect(
renderStyles(
{ entryPath: entry, appDir: root, appRoot: root, mode: "production" },
{
process: () => {
throw new Error("processor unavailable");
},
},
),
).rejects.toThrow("Stylesheet processing failed");
} finally {
rmSync(root, { recursive: true, force: true });
}
});
test("package CSS entries build without an application stylesheet", async () => {
const root = mkdtempSync(join(tmpdir(), "wrnexus-package-styles-"));
try {
const packageCss = join(root, "packages", "system", "system.css");
mkdirSync(join(root, "packages", "system"), { recursive: true });
writeFileSync(packageCss, ".system{}\n");
let wrapper = "";
const css = await renderStyles(
{
entryPath: null,
appDir: join(root, "app"),
appRoot: root,
mode: "development",
entries: [packageCss],
},
{
process: async ({ entryPath, originalEntryPath }) => {
expect(originalEntryPath).toBeNull();
wrapper = readFileSync(entryPath!, "utf8");
return ".package-compiled{}";
},
},
);
expect(css).toBe(".package-compiled{}");
expect(wrapper).toContain('@import "../../packages/system/system.css";');
} finally {
rmSync(root, { recursive: true, force: true });
}
});