diff --git a/.changeset/busy-plants-take.md b/.changeset/busy-plants-take.md new file mode 100644 index 00000000..9f36e1b6 --- /dev/null +++ b/.changeset/busy-plants-take.md @@ -0,0 +1,7 @@ +--- +'@css-modules-kit/ts-plugin': minor +'@css-modules-kit/codegen': minor +'@css-modules-kit/core': minor +--- + +feat: generate .d.ts files even if syntax or semantic errors are found diff --git a/packages/codegen/src/runner.test.ts b/packages/codegen/src/runner.test.ts index 04a35b57..a79ee4fb 100644 --- a/packages/codegen/src/runner.test.ts +++ b/packages/codegen/src/runner.test.ts @@ -204,6 +204,22 @@ describe('runCMK', () => { }, ] `); + // Even if there is a syntax error, .d.ts files are generated. + expect(await iff.readFile('generated/src/a.module.css.d.ts')).toMatchInlineSnapshot(` + "// @ts-nocheck + declare const styles = { + a1: '' as readonly string, + }; + export default styles; + " + `); + expect(await iff.readFile('generated/src/b.module.css.d.ts')).toMatchInlineSnapshot(` + "// @ts-nocheck + declare const styles = { + }; + export default styles; + " + `); }); test('reports semantic diagnostics in *.module.css', async () => { const iff = await createIFF({ @@ -247,6 +263,25 @@ describe('runCMK', () => { }, ] `); + // Even if there is a semantic error, .d.ts files are generated. + expect(await iff.readFile('generated/src/a.module.css.d.ts')).toMatchInlineSnapshot(` + "// @ts-nocheck + declare const styles = { + b_1: (await import('./b.module.css')).default.b_1, + b_2: (await import('./b.module.css')).default.b_2, + }; + export default styles; + " + `); + expect(await iff.readFile('generated/src/b.module.css.d.ts')).toMatchInlineSnapshot(` + "// @ts-nocheck + declare const styles = { + b_1: '' as readonly string, + ...(await import('./c.module.css')).default, + }; + export default styles; + " + `); }); }); diff --git a/packages/codegen/src/runner.ts b/packages/codegen/src/runner.ts index ebf0ce1e..afdf1910 100644 --- a/packages/codegen/src/runner.ts +++ b/packages/codegen/src/runner.ts @@ -34,7 +34,7 @@ async function parseCSSModuleByFileName(fileName: string, config: CMKConfig): Pr } catch (error) { throw new ReadCSSModuleFileError(fileName, error); } - return parseCSSModule(text, { fileName, safe: false, keyframes: config.keyframes }); + return parseCSSModule(text, { fileName, includeSyntaxError: true, keyframes: config.keyframes }); } /** @@ -101,6 +101,15 @@ export async function runCMK(args: ParsedArgs, logger: Logger): Promise { syntacticDiagnostics.push(...parseResult.diagnostics); } + if (args.clean) { + await rm(config.dtsOutDir, { recursive: true, force: true }); + } + await Promise.all( + parseResults.map(async (parseResult) => + writeDtsByCSSModule(parseResult.cssModule, config, resolver, matchesPattern), + ), + ); + if (syntacticDiagnostics.length > 0) { logger.logDiagnostics(syntacticDiagnostics); // eslint-disable-next-line n/no-process-exit @@ -120,13 +129,4 @@ export async function runCMK(args: ParsedArgs, logger: Logger): Promise { // eslint-disable-next-line n/no-process-exit process.exit(1); } - - if (args.clean) { - await rm(config.dtsOutDir, { recursive: true, force: true }); - } - await Promise.all( - parseResults.map(async (parseResult) => - writeDtsByCSSModule(parseResult.cssModule, config, resolver, matchesPattern), - ), - ); } diff --git a/packages/core/src/parser/css-module-parser.test.ts b/packages/core/src/parser/css-module-parser.test.ts index c659e633..52e5c48e 100644 --- a/packages/core/src/parser/css-module-parser.test.ts +++ b/packages/core/src/parser/css-module-parser.test.ts @@ -2,7 +2,7 @@ import dedent from 'dedent'; import { describe, expect, test } from 'vitest'; import { parseCSSModule, type ParseCSSModuleOptions } from './css-module-parser.js'; -const options: ParseCSSModuleOptions = { fileName: '/test.module.css', safe: false, keyframes: true }; +const options: ParseCSSModuleOptions = { fileName: '/test.module.css', includeSyntaxError: true, keyframes: true }; describe('parseCSSModule', () => { test('collects local tokens', () => { @@ -695,7 +695,35 @@ describe('parseCSSModule', () => { { "cssModule": { "fileName": "/test.module.css", - "localTokens": [], + "localTokens": [ + { + "declarationLoc": { + "end": { + "column": 6, + "line": 1, + "offset": 5, + }, + "start": { + "column": 1, + "line": 1, + "offset": 0, + }, + }, + "loc": { + "end": { + "column": 3, + "line": 1, + "offset": 2, + }, + "start": { + "column": 2, + "line": 1, + "offset": 1, + }, + }, + "name": "a", + }, + ], "text": ".a {", "tokenImporters": [], }, @@ -742,14 +770,15 @@ describe('parseCSSModule', () => { } `); }); - test('parses CSS in a fault-tolerant manner if safe is true', () => { - const parsed = parseCSSModule( - dedent` - .a { - `, - { ...options, safe: true }, - ); - expect(parsed).toMatchInlineSnapshot(` + test('does not include syntax error in diagnostics if includeSyntaxError is false', () => { + expect( + parseCSSModule( + dedent` + .a { + `, + { ...options, includeSyntaxError: false }, + ), + ).toMatchInlineSnapshot(` { "cssModule": { "fileName": "/test.module.css", diff --git a/packages/core/src/parser/css-module-parser.ts b/packages/core/src/parser/css-module-parser.ts index 97840f9d..da91a32b 100644 --- a/packages/core/src/parser/css-module-parser.ts +++ b/packages/core/src/parser/css-module-parser.ts @@ -78,7 +78,8 @@ function collectTokens(ast: Root, keyframes: boolean) { export interface ParseCSSModuleOptions { fileName: string; - safe: boolean; + /** Whether to include syntax errors from diagnostics */ + includeSyntaxError: boolean; keyframes: boolean; } @@ -87,33 +88,39 @@ export interface ParseCSSModuleResult { diagnostics: DiagnosticWithLocation[]; } +/** + * Parse CSS Module text. + * If a syntax error is detected in the text, it is re-parsed using `postcss-safe-parser`, and `localTokens` are collected as much as possible. + */ export function parseCSSModule( text: string, - { fileName, safe, keyframes }: ParseCSSModuleOptions, + { fileName, includeSyntaxError, keyframes }: ParseCSSModuleOptions, ): ParseCSSModuleResult { let ast: Root; - const diagnosticSourceFile = { fileName, text }; - try { - const parser = safe ? safeParser : parse; - ast = parser(text, { from: fileName }); - } catch (e) { - if (e instanceof CssSyntaxError) { + const diagnosticFile = { fileName, text }; + const allDiagnostics: DiagnosticWithLocation[] = []; + if (includeSyntaxError) { + try { + ast = parse(text, { from: fileName }); + } catch (e) { + if (!(e instanceof CssSyntaxError)) throw e; + // If syntax error, try to parse with safe parser. While this incurs a cost + // due to parsing the file twice, it rarely becomes an issue since files + // with syntax errors are usually few in number. + ast = safeParser(text, { from: fileName }); const { line, column, endColumn } = e.input!; - return { - cssModule: { fileName, text, localTokens: [], tokenImporters: [] }, - diagnostics: [ - { - file: diagnosticSourceFile, - start: { line, column }, - length: endColumn !== undefined ? endColumn - column : 1, - text: e.reason, - category: 'error', - }, - ], - }; + allDiagnostics.push({ + file: diagnosticFile, + start: { line, column }, + length: endColumn !== undefined ? endColumn - column : 1, + text: e.reason, + category: 'error', + }); } - throw e; + } else { + ast = safeParser(text, { from: fileName }); } + const { localTokens, tokenImporters, diagnostics } = collectTokens(ast, keyframes); const cssModule = { fileName, @@ -121,5 +128,6 @@ export function parseCSSModule( localTokens, tokenImporters, }; - return { cssModule, diagnostics: diagnostics.map((diagnostic) => ({ ...diagnostic, file: diagnosticSourceFile })) }; + allDiagnostics.push(...diagnostics.map((diagnostic) => ({ ...diagnostic, file: diagnosticFile }))); + return { cssModule, diagnostics: allDiagnostics }; } diff --git a/packages/ts-plugin/e2e-test/invalid-syntax.test.ts b/packages/ts-plugin/e2e-test/invalid-syntax.test.ts index ebc36930..ae48671f 100644 --- a/packages/ts-plugin/e2e-test/invalid-syntax.test.ts +++ b/packages/ts-plugin/e2e-test/invalid-syntax.test.ts @@ -44,9 +44,6 @@ describe('handle invalid syntax CSS without crashing', async () => { expect(normalizeDefinitions(res.body?.definitions ?? [])).toStrictEqual(normalizeDefinitions(expected)); }); test('does not report syntactic diagnostics', async () => { - // NOTE: The standard CSS Language Server reports invalid syntax errors. - // Therefore, if ts-plugin also reports it, the same error is reported twice. - // To avoid this, ts-plugin does not report invalid syntax errors. const res = await tsserver.sendSyntacticDiagnosticsSync({ file: iff.paths['a.module.css'], }); diff --git a/packages/ts-plugin/src/language-plugin.ts b/packages/ts-plugin/src/language-plugin.ts index e17875b4..c8733676 100644 --- a/packages/ts-plugin/src/language-plugin.ts +++ b/packages/ts-plugin/src/language-plugin.ts @@ -49,9 +49,10 @@ export function createCSSLanguagePlugin( const cssModuleCode = snapshot.getText(0, length); const { cssModule, diagnostics } = parseCSSModule(cssModuleCode, { fileName: scriptId, - // The CSS in the process of being written in an editor often contains invalid syntax. - // So, ts-plugin uses a fault-tolerant Parser to parse CSS. - safe: true, + // NOTE: The standard CSS Language Server reports invalid syntax errors. + // Therefore, if ts-plugin also reports it, the same error is reported twice. + // To avoid this, ts-plugin does not report invalid syntax errors. + includeSyntaxError: false, keyframes: config.keyframes, }); // eslint-disable-next-line prefer-const