diff --git a/.changeset/angry-kangaroos-relate.md b/.changeset/angry-kangaroos-relate.md new file mode 100644 index 0000000..4cc2b0c --- /dev/null +++ b/.changeset/angry-kangaroos-relate.md @@ -0,0 +1,5 @@ +--- +"@titanom/css2tailwind": patch +--- + +add error handling diff --git a/examples/standalone/package.json b/examples/standalone/package.json index cf9e669..81b63d0 100644 --- a/examples/standalone/package.json +++ b/examples/standalone/package.json @@ -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", diff --git a/packages/css2tailwind/package.json b/packages/css2tailwind/package.json index 84e4129..bbcbb45 100644 --- a/packages/css2tailwind/package.json +++ b/packages/css2tailwind/package.json @@ -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", @@ -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", diff --git a/packages/css2tailwind/src/build.ts b/packages/css2tailwind/src/build.ts index c41ccb3..42ca186 100644 --- a/packages/css2tailwind/src/build.ts +++ b/packages/css2tailwind/src/build.ts @@ -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 { const entries = await fsp.readdir(dir); @@ -14,16 +18,38 @@ export async function readStyles(dir: string): Promise { export async function parseStyles( dir: string, stylesDirectory: string, - tailwindConfigPath?: string, -): Promise { + tailwindConfig: Config, +): Promise> { 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) { diff --git a/packages/css2tailwind/src/cli.ts b/packages/css2tailwind/src/cli.ts index e57d096..026c005 100644 --- a/packages/css2tailwind/src/cli.ts +++ b/packages/css2tailwind/src/cli.ts @@ -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 ') @@ -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> { + 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')); + } +} diff --git a/packages/css2tailwind/src/compiler.ts b/packages/css2tailwind/src/compiler.ts index 1b2365d..8af053c 100644 --- a/packages/css2tailwind/src/compiler.ts +++ b/packages/css2tailwind/src/compiler.ts @@ -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 { - 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 => { @@ -34,20 +19,29 @@ function resolveImport(stylesDirectory: string): (id: string) => string { export async function compileStyleSheet( raw: string, stylesDirectory: string, - tailwindConfigPath?: string, + tailwindConfig: Config, ): Promise { - 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'); + } } diff --git a/packages/css2tailwind/src/error.ts b/packages/css2tailwind/src/error.ts index c9c1f69..009bb6b 100644 --- a/packages/css2tailwind/src/error.ts +++ b/packages/css2tailwind/src/error.ts @@ -1,5 +1,81 @@ -export class NoStylesDirectory extends Error { +import { bgRed, black, red } from 'colorette'; + +import type { CssSyntaxError } from 'postcss'; + +export class NoStylesDirectoryError extends Error { public constructor(message: string) { super(message); } } + +export class CloseWatcherError extends Error { + private readonly errorName = 'ERR_CLOSE_WATCHER'; + + public constructor(message: string) { + super(message); + } + + public override toString() { + return `${bgRed(` ${black(this.errorName)} `)} Failed to close watcher`; + } +} + +export class CompilationError extends Error { + public constructor(message: string) { + super(message); + } + + public override toString() { + return this.message; + } +} + +export class ResolveTailwindConfigError extends Error { + private readonly errorName = 'ERR_RESOLVE_TAILWIND_CONFIG'; + + public constructor(message: string) { + super(message); + } + + public override toString() { + return `${bgRed(` ${this.errorName} `)} ${red(this.message)}`; + } +} + +export class SyntaxError extends Error { + private readonly errorName = 'ERR_CSS_SYNTAX'; + + public constructor(public readonly error: CssSyntaxError) { + super(error.message); + } + + public override toString() { + return `${bgRed(` ${black(this.errorName)} `)} ${this.error.reason} at ${this.error.input?.file ?? this.error.file}:${this.error.input?.line}:${this.error.input?.column} + +${this.error.showSourceCode()} +`; + } + + public key() { + return `${this.error.line}:${this.error.column}:${this.error.reason}`; + } +} + +export function isSyntaxError(error: unknown): error is SyntaxError { + return error instanceof SyntaxError; +} + +export function dedupeSyntaxErrors(errors: SyntaxError[]) { + const uniqueErrors = new Map(); + + for (const error of errors) { + const key = error.key(); + // if an error has a file path, prefer it over the other one(s) + const hasFile = Boolean(error.error.input?.file ?? error.error.file); + if (!uniqueErrors.has(key) || hasFile) { + uniqueErrors.set(key, error); + } + } + + return Array.from(uniqueErrors.values()); +} diff --git a/packages/css2tailwind/src/util.ts b/packages/css2tailwind/src/util.ts new file mode 100644 index 0000000..e74458a --- /dev/null +++ b/packages/css2tailwind/src/util.ts @@ -0,0 +1,80 @@ +import { bundleRequire } from 'bundle-require'; + +import { ResolveTailwindConfigError } from './error'; + +import type { Config } from 'tailwindcss'; + +const defaultTailwindConfig: Config = { content: ['./**/*.css'] }; + +// TODO: logging, error handling +export async function readTailwindConfig(path?: string): Promise { + if (!path) return defaultTailwindConfig; + try { + const bundle = await bundleRequire({ + filepath: path, + preserveTemporaryFile: false, + }); + // 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) throw new Error(); + return config; + } catch { + throw new ResolveTailwindConfigError(`Failed to read tailwind configuration at ${path}.`); + } +} + +export type ResultOk = { ok: true; value: TValue }; +export type ResultErr = { ok: false; error: TError }; +export type Result = ResultOk | ResultErr; + +export function ok(value: TValue) { + return { ok: true, value } as const; +} +export function err(error: TError) { + return { ok: false, error } as const; +} + +export function isOk( + result: Result, +): result is { ok: true; value: TValue } { + return result.ok; +} + +export function isErr( + result: Result, +): result is { ok: false; error: TError } { + return !result.ok; +} + +export function mapOkResultToValue(result: ResultOk) { + return result.value; +} + +export function mapErrResultToError(result: ResultErr) { + return result.error; +} + +export function isPromiseFulfilled( + promiseResult: PromiseSettledResult, +): promiseResult is PromiseFulfilledResult { + return promiseResult.status === 'fulfilled'; +} + +export function isPromiseRejected( + promiseResult: PromiseSettledResult, +): promiseResult is PromiseRejectedResult { + return promiseResult.status === 'rejected'; +} + +export function mapPromiseRejectedResultToReason( + promiseRejectedResult: PromiseRejectedResult, +): unknown { + return promiseRejectedResult.reason; +} + +export function mapPromiseFulfilledResultToValue( + promiseRejectedResult: PromiseFulfilledResult, +): TValue { + return promiseRejectedResult.value; +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b0fdee6..55779ed 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -78,9 +78,15 @@ importers: packages/css2tailwind: dependencies: + '@babel/code-frame': + specifier: 7.23.5 + version: 7.23.5 bundle-require: specifier: 4.0.2 version: 4.0.2(esbuild@0.19.12) + colorette: + specifier: 2.0.20 + version: 2.0.20 postcss: specifier: 8.4.35 version: 8.4.35 @@ -100,6 +106,9 @@ importers: specifier: 3.22.4 version: 3.22.4 devDependencies: + '@types/babel__code-frame': + specifier: 7.0.6 + version: 7.0.6 '@types/node': specifier: 20.11.20 version: 20.11.20 @@ -144,7 +153,6 @@ packages: dependencies: '@babel/highlight': 7.23.4 chalk: 2.4.2 - dev: true /@babel/compat-data@7.23.5: resolution: {integrity: sha512-uU27kfDRlhfKl+w1U6vp16IuvSLtjAxdArVXPa9BvLkrr7CYIsxH5adpHObeAGY/41+syctUWOZ140a2Rvkgjw==} @@ -258,7 +266,6 @@ packages: /@babel/helper-validator-identifier@7.22.20: resolution: {integrity: sha512-Y4OZ+ytlatR8AI+8KZfKuL5urKp7qey08ha31L8b3BwewJAoJamTzyvxPR/5D+KkdJCGPq/+8TukHBlY10FX9A==} engines: {node: '>=6.9.0'} - dev: true /@babel/helper-validator-option@7.23.5: resolution: {integrity: sha512-85ttAOMLsr53VgXkTbkx8oA6YTfT4q7/HzXSLEYmjcSTJPMPQtvq1BD79Byep5xMUYbGRzEpDsjUf3dyp54IKw==} @@ -283,7 +290,6 @@ packages: '@babel/helper-validator-identifier': 7.22.20 chalk: 2.4.2 js-tokens: 4.0.0 - dev: true /@babel/parser@7.23.9: resolution: {integrity: sha512-9tcKgqKbs3xGJ+NtKF2ndOBBLVwPjl1SHxPQkd36r3Dlirw3xWUeGaTbqr7uGZcTaxkVNwc+03SVP7aCdWrTlA==} @@ -1155,6 +1161,10 @@ packages: resolution: {integrity: sha512-T0DQ96c3FdPXNhgc15AnCT/ATZFk2iX4gEFvS4tXVy0zLRR5PmlmoWCdjO+mxKUTyOaxBDnJ2dejcZNKkHzBgg==} dev: true + /@types/babel__code-frame@7.0.6: + resolution: {integrity: sha512-Anitqkl3+KrzcW2k77lRlg/GfLZLWXBuNgbEcIOU6M92yw42vsd3xV/Z/yAHEj8m+KUjL6bWOVOFqX8PFPJ4LA==} + dev: true + /@types/estree@1.0.5: resolution: {integrity: sha512-/kYRxGDLWzHOB7q+wtSUQlFrtcdUccpfy+X+9iMBpHK8QLLhx2wIPYuS5DYtR9Wa/YlZAbIovy7qVdB1Aq6Lyw==} dev: true @@ -1416,7 +1426,6 @@ packages: engines: {node: '>=4'} dependencies: color-convert: 1.9.3 - dev: true /ansi-styles@4.3.0: resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==} @@ -1682,7 +1691,6 @@ packages: ansi-styles: 3.2.1 escape-string-regexp: 1.0.5 supports-color: 5.5.0 - dev: true /chalk@4.1.2: resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==} @@ -1740,7 +1748,6 @@ packages: resolution: {integrity: sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==} dependencies: color-name: 1.1.3 - dev: true /color-convert@2.0.1: resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==} @@ -1750,11 +1757,14 @@ packages: /color-name@1.1.3: resolution: {integrity: sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw==} - dev: true /color-name@1.1.4: resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==} + /colorette@2.0.20: + resolution: {integrity: sha512-IfEDxwoWIjkeXL1eXcDiow4UbKjhLdq6/EuSVR9GMN7KVH3r9gQ83e73hsz1Nd1T3ijd5xv1wcWRYO+D6kCI2w==} + dev: false + /commander@4.1.1: resolution: {integrity: sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==} engines: {node: '>= 6'} @@ -2096,7 +2106,6 @@ packages: /escape-string-regexp@1.0.5: resolution: {integrity: sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg==} engines: {node: '>=0.8.0'} - dev: true /escape-string-regexp@4.0.0: resolution: {integrity: sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==} @@ -2586,7 +2595,6 @@ packages: /has-flag@3.0.0: resolution: {integrity: sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw==} engines: {node: '>=4'} - dev: true /has-flag@4.0.0: resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==} @@ -4126,7 +4134,6 @@ packages: engines: {node: '>=4'} dependencies: has-flag: 3.0.0 - dev: true /supports-color@7.2.0: resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==}