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