180 lines
5.5 KiB
TypeScript
180 lines
5.5 KiB
TypeScript
/**
|
|
* @wrnexus/styles — global stylesheet pipeline + app config.
|
|
*
|
|
* Works for SSR and CSR: the bundled stylesheet is `<link>`ed into every page's
|
|
* `<head>`, so it styles server-rendered markup and hydrated client islands
|
|
* alike. Use any CSS framework via `@import` in global.css (npm) or via a CDN
|
|
* link in `wrnexus.config.ts`'s `head` field.
|
|
*/
|
|
|
|
export type {
|
|
AppConfig,
|
|
AppConfigInput,
|
|
ResolvedAppConfig,
|
|
BuildConfig,
|
|
ConfigIssue,
|
|
DevToolbarConfig,
|
|
ExplainedConfig,
|
|
ExperimentalConfig,
|
|
MobileConfig,
|
|
NavigationConfig,
|
|
ObservabilityConfig,
|
|
PerformanceConfig,
|
|
TenancyConfig,
|
|
PwaConfig,
|
|
StylesConfig,
|
|
StyleProcessContext,
|
|
Mode,
|
|
ResolvedConfigLayers,
|
|
} from "./config.ts";
|
|
export {
|
|
defineConfig,
|
|
explainAppConfig,
|
|
headToString,
|
|
loadAppConfig,
|
|
loadEnv,
|
|
loadRawConfig,
|
|
resolveProfile,
|
|
resolveConfigLayers,
|
|
validateAppConfig,
|
|
} from "./config.ts";
|
|
export { findStyleEntry, bundleCss } from "./styles.ts";
|
|
export type { FontConfig, GoogleFont, LocalFontFace, FontDisplay } from "./fonts.ts";
|
|
export { renderFontHead, renderProductionFontHead, fontCspSources } from "./fonts.ts";
|
|
export type {
|
|
ThemeConfig,
|
|
ThemeTokens,
|
|
ResolvedTheme,
|
|
ThemePaletteName,
|
|
CustomThemePalette,
|
|
ThemeToken,
|
|
ThemeSemanticColor,
|
|
BrowserCookieOptions,
|
|
BrowserCookiesConfig,
|
|
BrowserCookiePreference,
|
|
BrowserCookieApi,
|
|
} from "./theme.ts";
|
|
export {
|
|
DEFAULT_THEMES,
|
|
THEME_PALETTES,
|
|
THEME_PALETTE_NAMES,
|
|
THEME_COOKIE,
|
|
THEME_CSS_HREF,
|
|
THEME_CSS_PREFIX,
|
|
THEME_JS_HREF,
|
|
activeThemeCssHref,
|
|
resolveThemeConfig,
|
|
resolveThemeName,
|
|
renderThemeCss,
|
|
renderActiveThemeCss,
|
|
renderThemeRuntime,
|
|
defineThemeTokens,
|
|
themeVar,
|
|
resolveBrowserCookieOptions,
|
|
} from "./theme.ts";
|
|
|
|
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.
|
|
*
|
|
* 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 && !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(processContext));
|
|
} catch (err) {
|
|
const msg = err instanceof Error ? err.message : String(err);
|
|
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(processContext.entryPath!, processContext.mode);
|
|
}
|
|
|
|
/**
|
|
* Best-effort CSS when a custom processor fails: try the built-in bundler; if that
|
|
* also fails (e.g. `@import "tailwindcss"` can't resolve), serve the raw entry with
|
|
* the tool-only directives stripped so the page still renders.
|
|
*/
|
|
async function fallbackCss(ctx: StyleProcessContext): Promise<string> {
|
|
try {
|
|
return await bundleCss(ctx.entryPath!, ctx.mode);
|
|
} catch {
|
|
try {
|
|
const raw = await Bun.file(ctx.entryPath!).text();
|
|
return raw.replace(/@import\s+["']tailwindcss["'];?/g, "").replace(/@source[^;\n]*;?/g, "");
|
|
} catch {
|
|
return "";
|
|
}
|
|
}
|
|
}
|
|
|
|
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 {
|
|
auditWrnTokens,
|
|
normalizeStyleSources,
|
|
tailwindSourceDirectives,
|
|
contrast,
|
|
auditCssPerformance,
|
|
} from "./audit.ts";
|
|
export type {
|
|
CssTokenAudit,
|
|
StyleSource,
|
|
ContrastResult,
|
|
CssPerformanceAuditIssue,
|
|
} from "./audit.ts";
|
|
export * from "./theme.ts";
|