Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: error handling #19

Merged
merged 10 commits into from
Mar 4, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/angry-kangaroos-relate.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@titanom/css2tailwind": patch
---

add error handling
2 changes: 1 addition & 1 deletion examples/standalone/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
"type": "module",
"private": true,
"scripts": {
"dev": "run-p vite styles:watch",
"dev": "run-p styles:watch vite",
"vite": "vite",
"build": "tsc && vite build",
"preview": "vite preview",
Expand Down
3 changes: 3 additions & 0 deletions packages/css2tailwind/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,9 @@
"release:ci": "pnpm build && pnpm publish --access public --provenance"
},
"dependencies": {
"@babel/code-frame": "7.23.5",
"bundle-require": "4.0.2",
"colorette": "2.0.20",
"postcss": "8.4.35",
"postcss-import": "16.0.1",
"postcss-js": "4.0.1",
Expand All @@ -36,6 +38,7 @@
"zod": "3.22.4"
},
"devDependencies": {
"@types/babel__code-frame": "7.0.6",
"@types/node": "20.11.20",
"@types/postcss-import": "14.0.3",
"@types/postcss-js": "4.0.4",
Expand Down
42 changes: 34 additions & 8 deletions packages/css2tailwind/src/build.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,10 @@ import * as path from 'node:path';
import type { CssInJs } from 'postcss-js';

import { compileStyleSheet } from './compiler';
import { isSyntaxError, type SyntaxError } from './error';
import { err, isPromiseFulfilled, mapPromiseFulfilledResultToValue, ok, type Result } from './util';

import type { Config } from 'tailwindcss';

export async function readStyles(dir: string): Promise<string[]> {
const entries = await fsp.readdir(dir);
Expand All @@ -14,16 +18,38 @@ export async function readStyles(dir: string): Promise<string[]> {
export async function parseStyles(
dir: string,
stylesDirectory: string,
tailwindConfigPath?: string,
): Promise<CssInJs> {
tailwindConfig: Config,
): Promise<Result<CssInJs, SyntaxError[] | Error>> {
const contents = await readStyles(dir);
const compiledStyles = await Promise.all(
contents.map(async (raw) => await compileStyleSheet(raw, stylesDirectory, tailwindConfigPath)),
);
try {
const compiledStyleResults = await Promise.allSettled(
contents.map(async (raw) => await compileStyleSheet(raw, stylesDirectory, tailwindConfig)),
);

const errors = compiledStyleResults
.filter(function (result): result is PromiseRejectedResult {
return result.status === 'rejected';
})
.map((result) => result.reason as unknown);

if (errors.length) {
const syntaxErrors = errors.filter(isSyntaxError);

return err(syntaxErrors);
}

const values = compiledStyleResults
.filter(isPromiseFulfilled)
.map(mapPromiseFulfilledResultToValue);

return compiledStyles.reduce((kind, style) => {
return { ...kind, ...style };
}, {});
return ok(
values.reduce((kind, style) => {
return { ...kind, ...style };
}, {}),
);
} catch (error) {
return err(new Error('unknown'));
}
}

export async function writeStyles(dir: string, entry: string, style: CssInJs) {
Expand Down
114 changes: 85 additions & 29 deletions packages/css2tailwind/src/cli.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,22 @@
import * as fsp from 'node:fs/promises';
import * as path from 'node:path';

import { watch } from 'chokidar';
import { watch, type FSWatcher } from 'chokidar';
import yargs from 'yargs';
import { hideBin } from 'yargs/helpers';
import { z } from 'zod';

import { bootstrapStyles, parseStyles, writeStyles } from './build';
import { NoStylesDirectory } from './error';
import {
CloseWatcherError,
dedupeSyntaxErrors,
isSyntaxError,
NoStylesDirectoryError,
type SyntaxError,
} from './error';
import { err, isErr, mapErrResultToError, ok, readTailwindConfig, type Result } from './util';

import type { Config } from 'tailwindcss';

const { argv } = yargs(hideBin(process.argv))
.usage('tgp <styles-directory> <output-directory>')
Expand Down Expand Up @@ -48,53 +57,100 @@ const args = schema.parse(argv);
const cwd = process.cwd();
const stylesDirectory = path.join(cwd, args.stylesDirectory);
const outputDirectory = path.join(cwd, args.outputDirectory);
const configPath = args.config ? path.join(cwd, args.config) : undefined;

async function assertDirExists(dir: string) {
try {
await fsp.stat(dir);
} catch (error) {
throw new NoStylesDirectory(`Styles Directory ${stylesDirectory} does not exist.`);
throw new NoStylesDirectoryError(`Styles Directory ${stylesDirectory} does not exist.`);
}
}

function exitIf(exit: boolean, code: number) {
if (exit) process.exit(code);
}

async function buildAndHandleErrors(entry: string, tailwindConfig: Config) {
const buildResult = await compileAndWriteStyles(entry, tailwindConfig);
if (!buildResult.ok) handleBuildErrors(buildResult.error);
return buildResult;
}

function handleBuildErrors(error: unknown) {
if (Array.isArray(error)) {
const syntaxErrors = dedupeSyntaxErrors(error.filter(isSyntaxError));
syntaxErrors.forEach((error) => console.log(error.toString()));
}
}

function setupWatcher(entryPath: string, entry: string, tailwindConfig: Config): FSWatcher {
const watcher = watch(`${entryPath}/*/*.css`, {
awaitWriteFinish: { stabilityThreshold: 10, pollInterval: 10 },
});
watcher.on('change', () => void buildAndHandleErrors(entry, tailwindConfig));
return watcher;
}

let watchers: FSWatcher[] = [];

function gracefullyShutdown() {
Promise.all(watchers.map(async (watcher) => await watcher.close()))
.then(() => process.exit(0))
.catch(() => {
console.error(new CloseWatcherError('').toString());
process.exit(1);
});
}

async function main() {
await assertDirExists(stylesDirectory);

const dirents = await fsp.readdir(stylesDirectory, { withFileTypes: true });
const entries = dirents.filter((dirent) => dirent.isDirectory()).map((dirent) => dirent.name);

await Promise.all(
await Promise.all(entries.map(async (entry) => bootstrapStyles(outputDirectory, entry)));

const tailwindConfig = await readTailwindConfig(args.config && path.join(cwd, args.config));

const buildResults = await Promise.all(
entries.map(async (entry) => {
await bootstrapStyles(outputDirectory, entry);
const styles = await parseStyles(
path.join(stylesDirectory, entry),
stylesDirectory,
configPath,
);
await writeStyles(outputDirectory, entry, styles);
return await buildAndHandleErrors(entry, tailwindConfig);
}),
);

if (!args.watch) process.exit(0);
const buildErrors = buildResults.filter(isErr).flatMap(mapErrResultToError);

for (const entry of entries) {
exitIf(!args.watch && buildErrors.length > 0, 1);
exitIf(!args.watch, 0);

entries.forEach((entry) => {
const entryPath = path.join(stylesDirectory, entry);
const watcher = watch(`${entryPath}/*/*.css`, {
awaitWriteFinish: { stabilityThreshold: 10, pollInterval: 10 },
});
watcher.on('change', () => {
void (async () => {
await bootstrapStyles(outputDirectory, entry);
const styles = await parseStyles(
path.join(stylesDirectory, entry),
stylesDirectory,
configPath,
);
await writeStyles(outputDirectory, entry, styles);
})();
});
}
setupWatcher(entryPath, entry, tailwindConfig);
});

process.on('SIGINT', gracefullyShutdown);
process.on('SIGTERM', gracefullyShutdown);
}

void main();

async function compileAndWriteStyles(
entry: string,
tailwindConfig: Config,
): Promise<Result<void, Error | SyntaxError[]>> {
try {
const stylesResult = await parseStyles(
path.join(stylesDirectory, entry),
stylesDirectory,
tailwindConfig,
);

if (stylesResult.ok) {
await writeStyles(outputDirectory, entry, stylesResult.value);
return ok(undefined);
}
return err(stylesResult.error);
} catch (error) {
return err(new Error('unknown'));
}
}
62 changes: 28 additions & 34 deletions packages/css2tailwind/src/compiler.ts
Original file line number Diff line number Diff line change
@@ -1,28 +1,13 @@
import * as path from 'node:path';

import { bundleRequire } from 'bundle-require';
import importPlugin from 'postcss-import';
import postcssJs, { type CssInJs } from 'postcss-js';
import nestingPlugin from 'tailwindcss/nesting';

import postcss, { type Root } from 'postcss';
import type { Config } from 'tailwindcss';
import tailwindPlugin from 'tailwindcss';

const defaultTailwindConfig: Config = { content: ['./**/*.css'] };

// TODO: logging, error handling
async function readTailwindConfig(path?: string): Promise<Config> {
if (!path) return defaultTailwindConfig;
const bundle = await bundleRequire({
filepath: path,
});
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unused-vars, @typescript-eslint/no-unsafe-member-access
const config = (bundle.mod.default ?? {}) as Config;
// TODO: log a warning
if (Object.keys(config).length <= 0) return defaultTailwindConfig;
return config;
}
import { SyntaxError } from './error';

import postcss, { CssSyntaxError, type Root } from 'postcss';
import tailwindPlugin, { type Config } from 'tailwindcss';

function resolveImport(stylesDirectory: string): (id: string) => string {
return (id: string): string => {
Expand All @@ -34,20 +19,29 @@ function resolveImport(stylesDirectory: string): (id: string) => string {
export async function compileStyleSheet(
raw: string,
stylesDirectory: string,
tailwindConfigPath?: string,
tailwindConfig: Config,
): Promise<CssInJs> {
const ast = postcss.parse(raw);

const processedAst = await postcss([
importPlugin({ resolve: resolveImport(stylesDirectory) }),
nestingPlugin(),
tailwindPlugin(await readTailwindConfig(tailwindConfigPath)),
]).process(ast, {
from: undefined,
parser: postcss.parse,
});

const cssInJs = postcssJs.objectify(processedAst.root as Root);

return cssInJs;
try {
const ast = postcss.parse(raw);
const processedAst = await postcss([
importPlugin({ resolve: resolveImport(stylesDirectory) }),
nestingPlugin(),
tailwindPlugin(tailwindConfig),
]).process(ast, {
// TODO: this should always be set
from: undefined,
parser: postcss.parse,
});

const cssInJs = postcssJs.objectify(processedAst.root as Root);

return cssInJs;
} catch (error) {
if (error instanceof CssSyntaxError) {
throw new SyntaxError(error);
}

// TODO: are there any other errors?
throw new Error('unknown error');
}
}
Loading
Loading