cmpl
Version:
no deps, no operations, no vowels - just a tiny compiler
355 lines (321 loc) • 9.11 kB
text/typescript
export interface Path {
join: (...segments: string[]) => string;
basename: (path: string, ext?: string) => string;
extname: (path: string) => string;
relative: (a: string, b: string) => string;
dirname: (path: string) => string;
}
export interface Fs {
readdir: (dir: string) => Promise<string[]>;
stat: (
dirOrFile: string,
) => Promise<{ isDirectory: () => boolean; mtimeMs: number }>;
readFile: (path: string) => Promise<Buffer>;
mkdir: (
path: string,
opts?: { recursive: true },
) => Promise<string | undefined>;
writeFile: (path: string, contents: Buffer) => Promise<void>;
}
export interface Crypto {
createHash: (algorithm: 'sha256') => {
update: (content: Buffer) => { digest: (encoding: 'hex') => string };
};
}
export type TransformFn = (
content: Buffer,
file: string,
) => Buffer | Promise<Buffer>;
export type FileNamerFn = (
originalName: string,
contents: Buffer,
) => string | Promise<string>;
export interface Prcssr {
outDir: string;
recursive?: boolean;
rename?: FileNamerFn;
include?: (name: string, isDir: boolean) => boolean;
transform?: TransformFn;
}
export interface CmplOptions {
entry: string;
processors: Prcssr[];
path?: Path | Promise<Path>;
fs?: Fs | Promise<Fs>;
}
export const cntntHsh =
(
length: number = 8,
crypto: Crypto | Promise<Crypto> = import('node:crypto'),
path: Path | Promise<Path> = import('node:path'),
): FileNamerFn =>
async (name, content) => {
const { createHash } = await crypto;
const { basename, extname } = await path;
return `${basename(name, extname(name))}-${createHash('sha256')
.update(content)
.digest('hex')
.substring(0, length)
.toUpperCase()}${extname(name)}`;
};
export async function prcss(
file: string,
{
entry,
fs = import('node:fs/promises'),
path = import('node:path'),
}: Pick<CmplOptions, 'entry' | 'fs' | 'path'>,
processors: (Prcssr | null)[],
): Promise<(null | Record<string, string>)[]> {
const { relative, dirname, join } = await path;
const { readFile, mkdir, writeFile } = await fs;
const contentsP = readFile(file);
const inName = relative(entry, file);
return Promise.all(
processors.map(async (p) => {
if (!p) {
return p;
}
const {
outDir,
transform = (b) => b,
rename = async (p) => (await path).basename(p),
} = p;
const contents = await transform(await contentsP, inName);
const name = await rename(inName, contents);
const targerDir = join(outDir, relative(entry, dirname(file)));
const targetFile = join(targerDir, name);
await mkdir(targerDir, { recursive: true });
await writeFile(targetFile, contents);
return { [inName]: relative(outDir, targetFile) };
}),
);
}
export async function cmpl({
entry,
processors,
fs = import('node:fs/promises'),
path = import('node:path'),
}: CmplOptions) {
const manifest: Record<string, string>[] = Array.from({
length: processors.length,
}).map(() => ({}));
const { relative, join, dirname } = await path;
const { readdir, stat } = await fs;
let entryDir: string | null;
const handle = async (
subEntry: string,
parentDir: string,
processors: (Prcssr | null)[],
) => {
const entryPath = join(parentDir, subEntry);
const isDir = (await stat(entryPath)).isDirectory();
if (!entryDir) {
entryDir = isDir ? entry : dirname(entry);
}
if (isDir) {
let relevantDir = false;
const dirProcessors = processors.map((p) => {
const incl =
p &&
(subEntry === entry ||
(p.recursive !== false &&
(!p.include || p.include(relative(entryDir!, entryPath), true))));
if (incl) {
relevantDir = true;
}
return incl ? p : null;
});
if (relevantDir) {
await readDir(entryPath, dirProcessors);
}
}
if (!isDir) {
let relevantFile = false;
const fileProcessors = processors.map((p) => {
const incl =
p && (!p.include || p.include(relative(entryDir!, entryPath), false))
? p
: null;
if (incl) {
relevantFile = true;
}
return incl;
});
if (!relevantFile) {
return;
}
(
await prcss(
entryPath,
{ fs, path, entry: entry === subEntry ? entryDir! : entry },
fileProcessors,
)
).forEach((v, i) => {
if (v !== null) {
Object.assign(manifest[i], v);
}
});
}
};
const readDir = async (
dir: string,
processors: (Prcssr | null)[],
): Promise<void> => {
await Promise.all(
(await readdir(dir)).map((e) => handle(e, dir, processors)),
);
};
await handle(entry, '', processors);
if (processors.length === 1) {
return manifest[0];
}
return manifest;
}
export interface WatchEvent {
eventType: 'rename' | 'change';
filename: string;
}
export interface WatchFs extends Fs {
watch: (
path: string,
opts?: { recursive: boolean; signal?: AbortSignal },
) => AsyncIterable<WatchEvent>;
}
export interface WtchOpts extends Omit<CmplOptions, 'fs'> {
signal?: AbortSignal;
fs?: WatchFs | Promise<WatchFs>;
poll?: boolean;
onError?: (err: unknown) => void;
}
export async function* wtch({
signal,
entry,
processors,
poll = Boolean(process.env.CMPL_USE_POLLING),
onError = process.env.CI
? (err) => {
throw err;
}
: (err) => console.log(err instanceof Error ? err.message : err),
fs = import('node:fs/promises'),
path = import('node:path'),
}: WtchOpts) {
const cmplOpts = {
entry,
fs,
processors,
path,
};
const { join, dirname } = await path;
const { watch, stat } = await fs;
let manifest: Record<string, string>[] | null = null;
const exportManifest = () =>
manifest!.length === 1
? Object.assign({}, manifest![0])
: manifest!.map((m) => Object.assign({}, m));
try {
const m = await cmpl(cmplOpts);
manifest = Array.isArray(m) ? m : [m];
yield exportManifest();
} catch (err) {
onError(err);
}
const isDir = (await stat(entry)).isDirectory();
const baseDir = isDir ? entry : dirname(entry);
const prcssOpts = isDir
? cmplOpts
: {
...cmplOpts,
entry: baseDir,
};
const recursive = processors.some(({ recursive }) => recursive !== false);
const wtchOrPll = poll ? createPll({ fs, path }) : watch;
for await (const event of wtchOrPll(entry, {
recursive: isDir ? recursive : false,
signal,
})) {
try {
if (!manifest) {
const m = await cmpl(cmplOpts);
manifest = Array.isArray(m) ? m : [m];
yield exportManifest();
} else {
switch (event.eventType) {
case 'rename': {
const exists = manifest.some((m) => m[event.filename]);
if (exists) {
manifest.forEach((m) => {
if (m[event.filename]) {
delete m[event.filename];
}
});
yield exportManifest();
break;
}
}
case 'change': {
let relevantChange = false;
const changeProcessors = processors.map((p) => {
const incl =
p && (!p.include || p.include(event.filename, false))
? p
: null;
if (incl) {
relevantChange = true;
}
return incl;
});
if (relevantChange) {
(
await prcss(
join(baseDir, event.filename),
prcssOpts,
changeProcessors,
)
).forEach((v, i) => {
if (v !== null) {
Object.assign(manifest![i], v);
}
});
yield exportManifest();
}
break;
}
}
}
} catch (err) {
onError(err);
}
}
}
function createPll({ path, fs }: Required<Pick<CmplOptions, 'path' | 'fs'>>) {
return async function* pll(
entry: string,
opts: { recursive: boolean; signal?: AbortSignal },
): AsyncIterable<WatchEvent> {
const { join, relative } = await path;
const { readdir, stat } = await fs;
const readDir = async (
dir: string,
state: Record<string, number> = {},
): Promise<Record<string, number>> => {
await Promise.all(
(
await readdir(dir)
).map(async (entryName) => {
const dirEntry = join(dir, entryName);
const { isDirectory, mtimeMs } = await stat(dirEntry);
if (isDirectory()) {
await readDir(dirEntry, state);
} else {
state[relative(entry, dirEntry)] = mtimeMs;
}
}),
);
return state;
};
const state = await readDir(entry);
console.log(state);
};
}