muddle/views/css/modules.ts
2026-10-23 19:14:28 -04:00

138 lines
3.9 KiB
TypeScript

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<string, string>;
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<StcssModule> {
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<string>,
): Promise<string> {
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;
}