138 lines
3.9 KiB
TypeScript
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;
|
|
}
|