import { existsSync } from "node:fs"; import { readFile } from "node:fs/promises"; import { dirname, join, resolve } from "node:path"; import { createRequire } from "node:module"; import { transform } from "lightningcss"; import { compileCss, type CompileOptions } from "./compiler.ts"; export interface StcssModule { /** The minified, scoped stylesheet. */ css: string; /** Original local class name to emitted class name. */ classes: Record; path: string; } export interface StcssModuleOptions extends CompileOptions { /** Path used to resolve relative and package imports. */ filename?: string; /** Project root used to find node_modules. */ root?: string; /** Emits compact CSS, enabled by default. */ minify?: boolean; } const require = createRequire(import.meta.url); /** * Compiles a component stylesheet. Local classes are deterministically scoped * and the resulting browser stylesheet is minified by Lightning CSS. */ export async function compileStcssModule( source: string, options: StcssModuleOptions = {}, ): Promise { const filename = options.filename ?? "component.module.stcss"; const imported = await inlineImports(source, filename, options, new Set()); const names = localClasses(imported); const classes = Object.fromEntries( names.map((name, index) => [name, mangle(filename, index)]), ); const scoped = imported.replace(/\.([_a-zA-Z][\w-]*)/g, (all, name) => classes[name] ? `.${classes[name]}` : all, ); const structured = compileCss(scoped, options); const css = transform({ filename, code: Buffer.from(structured), minify: options.minify !== false, }).code.toString(); return { css, classes, path: filename }; } function localClasses(source: string): string[] { return [ ...new Set( [...source.matchAll(/\.([_a-zA-Z][\w-]*)/g)].map( (match) => match[1] as string, ), ), ] .filter((name) => !name.startsWith("global-")) .sort(); } function mangle(filename: string, index: number): string { let hash = 5381; for (const char of filename) hash = (hash * 33) ^ char.charCodeAt(0); return `m${(hash >>> 0).toString(36)}${index.toString(36)}`; } async function inlineImports( source: string, from: string, options: StcssModuleOptions, seen: Set, ): Promise { const imports = [ ...source.matchAll(/@import\s+(?:url\()?\s*["']([^"']+)["']\s*\)?\s*;?/g), ]; let output = source; for (const match of imports) { const specifier = match[1] as string; if (/^(?:https?:|data:)/.test(specifier)) continue; const path = resolveStyle(specifier, from, options.root); if (!path || seen.has(path)) { output = output.replace(match[0], ""); continue; } seen.add(path); const nested = await inlineImports( await readFile(path, "utf8"), path, options, seen, ); output = output.replace(match[0], nested); } return output; } /** Resolves relative styles and stylesheet files exposed by installed packages. */ export function resolveStyle( specifier: string, from: string, root?: string, ): string | undefined { const candidates: string[] = []; if (specifier.startsWith(".")) { candidates.push(resolve(dirname(from), specifier)); } else { try { candidates.push( require.resolve(specifier, { paths: [dirname(from), root].filter(Boolean) as string[], }), ); } catch {} const packageName = specifier.startsWith("@") ? specifier.split("/").slice(0, 2).join("/") : (specifier.split("/")[0] as string); const rest = specifier.slice(packageName.length).replace(/^\//, ""); for (const base of [dirname(from), root].filter(Boolean) as string[]) { candidates.push(join(base, "node_modules", packageName, rest)); } } for (const candidate of candidates) { for (const path of [ candidate, `${candidate}.stcss`, `${candidate}.css`, join(candidate, "index.stcss"), join(candidate, "index.css"), ]) { if (existsSync(path)) return path; } } return undefined; }