diff --git a/packages/example/01-basic/1.css b/packages/example/01-basic/1.css index 103d710..5dccf20 100644 --- a/packages/example/01-basic/1.css +++ b/packages/example/01-basic/1.css @@ -33,3 +33,4 @@ } :local(.local_class_name_4) { } +@value value: #BF4040; diff --git a/packages/example/01-basic/1.css.d.ts b/packages/example/01-basic/1.css.d.ts index a4f5be4..72b8dee 100644 --- a/packages/example/01-basic/1.css.d.ts +++ b/packages/example/01-basic/1.css.d.ts @@ -16,6 +16,7 @@ declare const styles: & Readonly<{ "local_class_name_2": string }> & Readonly<{ "local_class_name_3": string }> & Readonly<{ "local_class_name_4": string }> + & Readonly<{ "value": string }> ; export default styles; //# sourceMappingURL=./1.css.d.ts.map diff --git a/packages/example/01-basic/1.css.d.ts.map b/packages/example/01-basic/1.css.d.ts.map index 582e750..0719883 100644 --- a/packages/example/01-basic/1.css.d.ts.map +++ b/packages/example/01-basic/1.css.d.ts.map @@ -1 +1 @@ -{"version":3,"sources":["./1.css"],"names":["basic","cascading","pseudo_class_1","pseudo_class_2","pseudo_class_3","multiple_selector_1","multiple_selector_2","combinator_1","combinator_2","at_rule","selector_list_1","selector_list_2","local_class_name_1","local_class_name_2","local_class_name_3","local_class_name_4"],"mappings":"AAAA;AAAA,E,aAAAA,O,WAAA;AAAA,E,aAEAC,W,WAFA;AAAA,E,aAIAA,W,WAJA;AAAA,E,aAMAC,gB,WANA;AAAA,E,aAQAC,gB,WARA;AAAA,E,aAUAC,gB,WAVA;AAAA,E,aAYAC,qB,WAZA;AAAA,E,aAYAC,qB,WAZA;AAAA,E,aAcAC,c,WAdA;AAAA,E,aAcAC,c,WAdA;AAAA,E,aAkBIC,S,WAlBJ;AAAA,E,aAsBAC,iB,WAtBA;AAAA,E,aAAAC,iB,WAAA;AAAA,E,aAyBAC,oB,WAzBA;AAAA,E,aA4BEC,oB,WA5BF;AAAA,E,aA8BEC,oB,WA9BF;AAAA,E,aAiCAC,oB,WAjCA;AAAA;AAAA","file":"1.css.d.ts","sourceRoot":""} \ No newline at end of file +{"version":3,"sources":["./1.css"],"names":["basic","cascading","pseudo_class_1","pseudo_class_2","pseudo_class_3","multiple_selector_1","multiple_selector_2","combinator_1","combinator_2","at_rule","selector_list_1","selector_list_2","local_class_name_1","local_class_name_2","local_class_name_3","local_class_name_4","value"],"mappings":"AAAA;AAAA,E,aAAAA,O,WAAA;AAAA,E,aAEAC,W,WAFA;AAAA,E,aAIAA,W,WAJA;AAAA,E,aAMAC,gB,WANA;AAAA,E,aAQAC,gB,WARA;AAAA,E,aAUAC,gB,WAVA;AAAA,E,aAYAC,qB,WAZA;AAAA,E,aAYAC,qB,WAZA;AAAA,E,aAcAC,c,WAdA;AAAA,E,aAcAC,c,WAdA;AAAA,E,aAkBIC,S,WAlBJ;AAAA,E,aAsBAC,iB,WAtBA;AAAA,E,aAAAC,iB,WAAA;AAAA,E,aAyBAC,oB,WAzBA;AAAA,E,aA4BEC,oB,WA5BF;AAAA,E,aA8BEC,oB,WA9BF;AAAA,E,aAiCAC,oB,WAjCA;AAAA,E,aAmCOC,O,WAnCP;AAAA;AAAA","file":"1.css.d.ts","sourceRoot":""} \ No newline at end of file diff --git a/packages/example/08-value-from/1.css b/packages/example/08-value-from/1.css new file mode 100644 index 0000000..e91d3b5 --- /dev/null +++ b/packages/example/08-value-from/1.css @@ -0,0 +1,4 @@ +@value value1 from './2.css'; /* single token */ +@value value2, value3 from './2.css'; /* multiple tokens */ +@value value4 as alias from './2.css'; /* alias */ +@value value5 from './2.css'; /* re-exported token */ diff --git a/packages/example/08-value-from/1.css.d.ts b/packages/example/08-value-from/1.css.d.ts new file mode 100644 index 0000000..614db76 --- /dev/null +++ b/packages/example/08-value-from/1.css.d.ts @@ -0,0 +1,9 @@ +declare const styles: + & Readonly> + & Readonly> + & Readonly> + & Readonly<{ "alias": (typeof import("./2.css"))["default"]["value4"] }> + & Readonly> +; +export default styles; +//# sourceMappingURL=./1.css.d.ts.map diff --git a/packages/example/08-value-from/1.css.d.ts.map b/packages/example/08-value-from/1.css.d.ts.map new file mode 100644 index 0000000..a1eab44 --- /dev/null +++ b/packages/example/08-value-from/1.css.d.ts.map @@ -0,0 +1 @@ +{"version":3,"sources":["./1.css","./2.css"],"names":["alias"],"mappings":"AAAA;AAAA,E,iEAAA;AAAA,E,iEAAA;AAAA,E,iEAAA;AAAA,E,aCGOA,O,oDDHP;AAAA,E,iEAAA;AAAA;AAAA","file":"1.css.d.ts","sourceRoot":""} \ No newline at end of file diff --git a/packages/example/08-value-from/2.css b/packages/example/08-value-from/2.css new file mode 100644 index 0000000..2dd66d6 --- /dev/null +++ b/packages/example/08-value-from/2.css @@ -0,0 +1,5 @@ +@value value1: ''; +@value value2: ''; +@value value3: ''; +@value value4: ''; +@value value5 from './3.css'; diff --git a/packages/example/08-value-from/2.css.d.ts b/packages/example/08-value-from/2.css.d.ts new file mode 100644 index 0000000..11cb225 --- /dev/null +++ b/packages/example/08-value-from/2.css.d.ts @@ -0,0 +1,9 @@ +declare const styles: + & Readonly<{ "value1": string }> + & Readonly<{ "value2": string }> + & Readonly<{ "value3": string }> + & Readonly<{ "value4": string }> + & Readonly> +; +export default styles; +//# sourceMappingURL=./2.css.d.ts.map diff --git a/packages/example/08-value-from/2.css.d.ts.map b/packages/example/08-value-from/2.css.d.ts.map new file mode 100644 index 0000000..e448a49 --- /dev/null +++ b/packages/example/08-value-from/2.css.d.ts.map @@ -0,0 +1 @@ +{"version":3,"sources":["./2.css"],"names":["value1","value2","value3","value4"],"mappings":"AAAA;AAAA,E,aAAOA,Q,WAAP;AAAA,E,aACOC,Q,WADP;AAAA,E,aAEOC,Q,WAFP;AAAA,E,aAGOC,Q,WAHP;AAAA,E,iEAAA;AAAA;AAAA","file":"2.css.d.ts","sourceRoot":""} \ No newline at end of file diff --git a/packages/example/08-value-from/3.css b/packages/example/08-value-from/3.css new file mode 100644 index 0000000..1b43241 --- /dev/null +++ b/packages/example/08-value-from/3.css @@ -0,0 +1 @@ +@value value5: ''; diff --git a/packages/example/08-value-from/3.css.d.ts b/packages/example/08-value-from/3.css.d.ts new file mode 100644 index 0000000..13b270c --- /dev/null +++ b/packages/example/08-value-from/3.css.d.ts @@ -0,0 +1,5 @@ +declare const styles: + & Readonly<{ "value5": string }> +; +export default styles; +//# sourceMappingURL=./3.css.d.ts.map diff --git a/packages/example/08-value-from/3.css.d.ts.map b/packages/example/08-value-from/3.css.d.ts.map new file mode 100644 index 0000000..54602f3 --- /dev/null +++ b/packages/example/08-value-from/3.css.d.ts.map @@ -0,0 +1 @@ +{"version":3,"sources":["./3.css"],"names":["value5"],"mappings":"AAAA;AAAA,E,aAAOA,Q,WAAP;AAAA;AAAA","file":"3.css.d.ts","sourceRoot":""} \ No newline at end of file diff --git a/packages/example/app.ts b/packages/example/app.ts index 95d1cff..29518f4 100644 --- a/packages/example/app.ts +++ b/packages/example/app.ts @@ -4,6 +4,7 @@ import styles3 from './03-composes/1.css'; import styles4 from './04-sass/1.scss'; import styles5 from './05-less/1.less'; import styles6 from './06-postcss/1.css'; +import styles8 from './08-value-from/1.css'; console.log(styles1.basic); console.log(styles1.cascading); @@ -22,6 +23,7 @@ console.log(styles1.local_class_name_1); console.log(styles1.local_class_name_2); console.log(styles1.local_class_name_3); console.log(styles1.local_class_name_4); +console.log(styles1.value); console.log(styles2.a); console.log(styles2.b); @@ -44,3 +46,9 @@ console.log(styles5.b_1); console.log(styles6.a_1); console.log(styles6.a_2); console.log(styles6.b); + +console.log(styles8.value1); +console.log(styles8.value2); +console.log(styles8.value3); +console.log(styles8.alias); +console.log(styles8.value5); diff --git a/packages/happy-css-modules/src/emitter/dts.test.ts b/packages/happy-css-modules/src/emitter/dts.test.ts index bf012c2..34bbd68 100644 --- a/packages/happy-css-modules/src/emitter/dts.test.ts +++ b/packages/happy-css-modules/src/emitter/dts.test.ts @@ -211,9 +211,9 @@ describe('generateDtsContentWithSourceMap', () => { }); test('emit other directory', async () => { createFixtures({ - '/test/1.css': `.a {}`, + '/test/src/1.css': `.a {}`, }); - const result = await locator.load(filePath); + const result = await locator.load(getFixturePath('/test/src/1.css')); const { dtsContent, sourceMap } = generateDtsContentWithSourceMap( getFixturePath('/test/src/1.css'), getFixturePath('/test/dist/1.css.d.ts'), @@ -224,7 +224,7 @@ describe('generateDtsContentWithSourceMap', () => { ); expect(dtsContent).toMatchInlineSnapshot(` "declare const styles: - & Readonly> + & Readonly<{ "a": string }> ; export default styles; " diff --git a/packages/happy-css-modules/src/emitter/dts.ts b/packages/happy-css-modules/src/emitter/dts.ts index 93a3582..c603fe7 100644 --- a/packages/happy-css-modules/src/emitter/dts.ts +++ b/packages/happy-css-modules/src/emitter/dts.ts @@ -28,18 +28,26 @@ function dashesCamelCase(str: string): string { } function formatTokens(tokens: Token[], localsConvention: LocalsConvention): Token[] { + function formatToken(token: Token, formatter: (str: string) => string): Token { + if ('importedName' in token && typeof token.importedName === 'string') { + return { ...token, name: formatter(token.name), importedName: formatter(token.importedName) }; + } else { + return { ...token, name: formatter(token.name) }; + } + } + const result: Token[] = []; for (const token of tokens) { if (localsConvention === 'camelCaseOnly') { - result.push({ ...token, name: camelcase(token.name) }); + result.push(formatToken(token, camelcase)); } else if (localsConvention === 'camelCase') { result.push(token); - result.push({ ...token, name: camelcase(token.name) }); + result.push(formatToken(token, camelcase)); } else if (localsConvention === 'dashesOnly') { - result.push({ ...token, name: dashesCamelCase(token.name) }); + result.push(formatToken(token, dashesCamelCase)); } else if (localsConvention === 'dashes') { result.push(token); - result.push({ ...token, name: dashesCamelCase(token.name) }); + result.push(formatToken(token, dashesCamelCase)); } else { result.push(token); // asIs } @@ -86,6 +94,21 @@ function generateTokenDeclarations( ), ': string }>', ]) + : typeof token.importedName === 'string' + ? new SourceNode(null, null, null, [ + `& Readonly<{ `, + new SourceNode( + originalLocation.start.line ?? null, + // The SourceNode's column is 0-based, but the originalLocation's column is 1-based. + originalLocation.start.column - 1 ?? null, + getRelativePath(sourceMapFilePath, originalLocation.filePath), + `"${token.name}"`, + token.name, + ), + `: (typeof import(`, + `"${getRelativePath(filePath, originalLocation.filePath)}"`, + `))["default"]["${token.importedName}"] }>`, + ]) : // Imported tokens in non-external files are typed by dynamic import. // See https://github.com/mizdra/happy-css-modules/issues/106. new SourceNode(null, null, null, [ diff --git a/packages/happy-css-modules/src/locator/index.test.ts b/packages/happy-css-modules/src/locator/index.test.ts index 7606fe2..c3cdc23 100644 --- a/packages/happy-css-modules/src/locator/index.test.ts +++ b/packages/happy-css-modules/src/locator/index.test.ts @@ -143,6 +143,57 @@ test('does not track other files by `composes`', async () => { `); }); +test('tracks other files when `@value` is present', async () => { + createFixtures({ + '/test/1.css': dedent` + @value a from './2.css'; + @value b from '3.css'; + @value c from '${getFixturePath('/test/4.css')}'; + `, + '/test/2.css': dedent` + @value a: 1; + `, + '/test/3.css': dedent` + @value b: 2; + `, + '/test/4.css': dedent` + @value c: 3; + `, + }); + const result = await locator.load(getFixturePath('/test/1.css')); + expect(result).toMatchInlineSnapshot(` + { + dependencies: ["/test/2.css", "/test/3.css", "/test/4.css"], + tokens: [ + { + name: "a", + originalLocation: { + filePath: "/test/2.css", + start: { line: 1, column: 8 }, + end: { line: 1, column: 9 }, + }, + }, + { + name: "b", + originalLocation: { + filePath: "/test/3.css", + start: { line: 1, column: 8 }, + end: { line: 1, column: 9 }, + }, + }, + { + name: "c", + originalLocation: { + filePath: "/test/4.css", + start: { line: 1, column: 8 }, + end: { line: 1, column: 9 }, + }, + }, + ], + } + `); +}); + test('unique tokens', async () => { createFixtures({ '/test/1.css': dedent` diff --git a/packages/happy-css-modules/src/locator/index.ts b/packages/happy-css-modules/src/locator/index.ts index 823f711..dffc1c6 100644 --- a/packages/happy-css-modules/src/locator/index.ts +++ b/packages/happy-css-modules/src/locator/index.ts @@ -4,7 +4,15 @@ import type { Resolver } from '../resolver/index.js'; import { createDefaultResolver } from '../resolver/index.js'; import { createDefaultTransformer, type Transformer } from '../transformer/index.js'; import { unique, uniqueBy } from '../util.js'; -import { getOriginalLocation, generateLocalTokenNames, parseAtImport, type Location, collectNodes } from './postcss.js'; +import { + getOriginalLocationOfClassSelector, + getOriginalLocationOfAtValue, + generateLocalTokenNames, + parseAtImport, + type Location, + collectNodes, + parseAtValue, +} from './postcss.js'; export { collectNodes, type Location } from './postcss.js'; @@ -20,6 +28,8 @@ function isIgnoredSpecifier(specifier: string): boolean { export type Token = { /** The token name. */ name: string; + /** The name of the imported token. */ + importedName?: string; /** The original location of the token in the source file. */ originalLocation: Location; }; @@ -142,7 +152,7 @@ export class Locator { const tokens: Token[] = []; - const { atImports, classSelectors } = collectNodes(ast); + const { atImports, atValues, classSelectors } = collectNodes(ast); // Load imported sheets recursively. for (const atImport of atImports) { @@ -164,7 +174,7 @@ export class Locator { // NOTE: This method has false positives. However, it works as expected in many cases. if (!localTokenNames.includes(classSelector.value)) continue; - const originalLocation = getOriginalLocation(rule, classSelector); + const originalLocation = getOriginalLocationOfClassSelector(rule, classSelector); tokens.push({ name: classSelector.value, @@ -172,6 +182,40 @@ export class Locator { }); } + for (const atValue of atValues) { + const parsedAtValue = parseAtValue(atValue); + + if (parsedAtValue.type === 'valueDeclaration') { + tokens.push({ + name: parsedAtValue.tokenName, + originalLocation: getOriginalLocationOfAtValue(atValue, parsedAtValue), + }); + } else if (parsedAtValue.type === 'valueImportDeclaration') { + if (isIgnoredSpecifier(parsedAtValue.from)) continue; + // eslint-disable-next-line no-await-in-loop + const from = await this.resolver(parsedAtValue.from, { request: filePath }); + // eslint-disable-next-line no-await-in-loop + const result = await this._load(from); + dependencies.push(from, ...result.dependencies); + for (const token of result.tokens) { + const matchedImport = parsedAtValue.imports.find((i) => i.importedTokenName === token.name); + if (!matchedImport) continue; + if (matchedImport.localTokenName === matchedImport.importedTokenName) { + tokens.push({ + name: matchedImport.localTokenName, + originalLocation: token.originalLocation, + }); + } else { + tokens.push({ + name: matchedImport.localTokenName, + importedName: matchedImport.importedTokenName, + originalLocation: token.originalLocation, + }); + } + } + } + } + const result: LoadResult = { dependencies: unique(dependencies).filter((dep) => dep !== filePath), tokens: uniqueBy(tokens, (token) => JSON.stringify(token)), diff --git a/packages/happy-css-modules/src/locator/postcss.test.ts b/packages/happy-css-modules/src/locator/postcss.test.ts index 7516f8c..0b3960a 100644 --- a/packages/happy-css-modules/src/locator/postcss.test.ts +++ b/packages/happy-css-modules/src/locator/postcss.test.ts @@ -1,6 +1,20 @@ import dedent from 'dedent'; -import { createRoot, createClassSelectors, createAtImports, createFixtures } from '../test-util/util.js'; -import { generateLocalTokenNames, getOriginalLocation, parseAtImport, collectNodes } from './postcss.js'; +import type { AtRule } from 'postcss'; +import { + createRoot, + createClassSelectors, + createAtImports, + createFixtures, + createAtValues, +} from '../test-util/util.js'; +import { + generateLocalTokenNames, + getOriginalLocationOfClassSelector, + parseAtImport, + parseAtValue, + collectNodes, + getOriginalLocationOfAtValue, +} from './postcss.js'; describe('generateLocalTokenNames', () => { test('basic', async () => { @@ -27,9 +41,11 @@ describe('generateLocalTokenNames', () => { .local_class_name_3 {} } :local(.local_class_name_4) {} + @value value: #BF4040; `), ), ).toStrictEqual([ + 'value', 'basic', 'cascading', 'pseudo_class_1', @@ -63,14 +79,18 @@ describe('generateLocalTokenNames', () => { ).toStrictEqual([]); }); test('does not track styles imported by @value in other file because it is not a local token', async () => { - createFixtures({}); + createFixtures({ + '/test/1.css': dedent` + .a {} + `, + }); expect( await generateLocalTokenNames( createRoot(` - @value something from "/test/1.css"; + @value a from "/test/1.css"; `), ), - ).toStrictEqual([]); + ).toStrictEqual(['a']); }); test('does not track styles imported by composes in other file because it is not a local token', async () => { createFixtures({ @@ -90,14 +110,14 @@ describe('generateLocalTokenNames', () => { }); }); -describe('getOriginalLocation', () => { +describe('getOriginalLocationOfClassSelector', () => { test('basic', () => { const [basic] = createClassSelectors( createRoot(dedent` .basic {} `), ); - expect(getOriginalLocation(basic!.rule, basic!.classSelector)).toMatchInlineSnapshot( + expect(getOriginalLocationOfClassSelector(basic!.rule, basic!.classSelector)).toMatchInlineSnapshot( `{ filePath: "/test/test.css", start: { line: 1, column: 1 }, end: { line: 1, column: 6 } }`, ); }); @@ -109,10 +129,10 @@ describe('getOriginalLocation', () => { .cascading {} `), ); - expect(getOriginalLocation(cascading_1!.rule, cascading_1!.classSelector)).toMatchInlineSnapshot( + expect(getOriginalLocationOfClassSelector(cascading_1!.rule, cascading_1!.classSelector)).toMatchInlineSnapshot( `{ filePath: "/test/test.css", start: { line: 1, column: 1 }, end: { line: 1, column: 10 } }`, ); - expect(getOriginalLocation(cascading_2!.rule, cascading_2!.classSelector)).toMatchInlineSnapshot( + expect(getOriginalLocationOfClassSelector(cascading_2!.rule, cascading_2!.classSelector)).toMatchInlineSnapshot( `{ filePath: "/test/test.css", start: { line: 2, column: 1 }, end: { line: 2, column: 10 } }`, ); }); @@ -125,13 +145,19 @@ describe('getOriginalLocation', () => { :not(.pseudo_class_3) {} `), ); - expect(getOriginalLocation(pseudo_class_1!.rule, pseudo_class_1!.classSelector)).toMatchInlineSnapshot( + expect( + getOriginalLocationOfClassSelector(pseudo_class_1!.rule, pseudo_class_1!.classSelector), + ).toMatchInlineSnapshot( `{ filePath: "/test/test.css", start: { line: 1, column: 1 }, end: { line: 1, column: 15 } }`, ); - expect(getOriginalLocation(pseudo_class_2!.rule, pseudo_class_2!.classSelector)).toMatchInlineSnapshot( + expect( + getOriginalLocationOfClassSelector(pseudo_class_2!.rule, pseudo_class_2!.classSelector), + ).toMatchInlineSnapshot( `{ filePath: "/test/test.css", start: { line: 2, column: 1 }, end: { line: 2, column: 15 } }`, ); - expect(getOriginalLocation(pseudo_class_3!.rule, pseudo_class_3!.classSelector)).toMatchInlineSnapshot( + expect( + getOriginalLocationOfClassSelector(pseudo_class_3!.rule, pseudo_class_3!.classSelector), + ).toMatchInlineSnapshot( `{ filePath: "/test/test.css", start: { line: 3, column: 6 }, end: { line: 3, column: 20 } }`, ); }); @@ -142,10 +168,14 @@ describe('getOriginalLocation', () => { .multiple_selector_1.multiple_selector_2 {} `), ); - expect(getOriginalLocation(multiple_selector_1!.rule, multiple_selector_1!.classSelector)).toMatchInlineSnapshot( + expect( + getOriginalLocationOfClassSelector(multiple_selector_1!.rule, multiple_selector_1!.classSelector), + ).toMatchInlineSnapshot( `{ filePath: "/test/test.css", start: { line: 1, column: 1 }, end: { line: 1, column: 20 } }`, ); - expect(getOriginalLocation(multiple_selector_2!.rule, multiple_selector_2!.classSelector)).toMatchInlineSnapshot( + expect( + getOriginalLocationOfClassSelector(multiple_selector_2!.rule, multiple_selector_2!.classSelector), + ).toMatchInlineSnapshot( `{ filePath: "/test/test.css", start: { line: 1, column: 21 }, end: { line: 1, column: 40 } }`, ); }); @@ -157,10 +187,10 @@ describe('getOriginalLocation', () => { .combinator_1 + .combinator_2 {} `), ); - expect(getOriginalLocation(combinator_1!.rule, combinator_1!.classSelector)).toMatchInlineSnapshot( + expect(getOriginalLocationOfClassSelector(combinator_1!.rule, combinator_1!.classSelector)).toMatchInlineSnapshot( `{ filePath: "/test/test.css", start: { line: 1, column: 1 }, end: { line: 1, column: 13 } }`, ); - expect(getOriginalLocation(combinator_2!.rule, combinator_2!.classSelector)).toMatchInlineSnapshot( + expect(getOriginalLocationOfClassSelector(combinator_2!.rule, combinator_2!.classSelector)).toMatchInlineSnapshot( `{ filePath: "/test/test.css", start: { line: 1, column: 17 }, end: { line: 1, column: 29 } }`, ); }); @@ -175,7 +205,7 @@ describe('getOriginalLocation', () => { } `), ); - expect(getOriginalLocation(at_rule!.rule, at_rule!.classSelector)).toMatchInlineSnapshot( + expect(getOriginalLocationOfClassSelector(at_rule!.rule, at_rule!.classSelector)).toMatchInlineSnapshot( `{ filePath: "/test/test.css", start: { line: 3, column: 5 }, end: { line: 3, column: 12 } }`, ); }); @@ -186,10 +216,14 @@ describe('getOriginalLocation', () => { .selector_list_1, .selector_list_2 {} `), ); - expect(getOriginalLocation(selector_list_1!.rule, selector_list_1!.classSelector)).toMatchInlineSnapshot( + expect( + getOriginalLocationOfClassSelector(selector_list_1!.rule, selector_list_1!.classSelector), + ).toMatchInlineSnapshot( `{ filePath: "/test/test.css", start: { line: 1, column: 1 }, end: { line: 1, column: 16 } }`, ); - expect(getOriginalLocation(selector_list_2!.rule, selector_list_2!.classSelector)).toMatchInlineSnapshot( + expect( + getOriginalLocationOfClassSelector(selector_list_2!.rule, selector_list_2!.classSelector), + ).toMatchInlineSnapshot( `{ filePath: "/test/test.css", start: { line: 1, column: 19 }, end: { line: 1, column: 34 } }`, ); }); @@ -205,16 +239,24 @@ describe('getOriginalLocation', () => { :local(.local_class_name_4) {} `), ); - expect(getOriginalLocation(local_class_name_1!.rule, local_class_name_1!.classSelector)).toMatchInlineSnapshot( + expect( + getOriginalLocationOfClassSelector(local_class_name_1!.rule, local_class_name_1!.classSelector), + ).toMatchInlineSnapshot( `{ filePath: "/test/test.css", start: { line: 1, column: 8 }, end: { line: 1, column: 26 } }`, ); - expect(getOriginalLocation(local_class_name_2!.rule, local_class_name_2!.classSelector)).toMatchInlineSnapshot( + expect( + getOriginalLocationOfClassSelector(local_class_name_2!.rule, local_class_name_2!.classSelector), + ).toMatchInlineSnapshot( `{ filePath: "/test/test.css", start: { line: 3, column: 3 }, end: { line: 3, column: 21 } }`, ); - expect(getOriginalLocation(local_class_name_3!.rule, local_class_name_3!.classSelector)).toMatchInlineSnapshot( + expect( + getOriginalLocationOfClassSelector(local_class_name_3!.rule, local_class_name_3!.classSelector), + ).toMatchInlineSnapshot( `{ filePath: "/test/test.css", start: { line: 4, column: 3 }, end: { line: 4, column: 21 } }`, ); - expect(getOriginalLocation(local_class_name_4!.rule, local_class_name_4!.classSelector)).toMatchInlineSnapshot( + expect( + getOriginalLocationOfClassSelector(local_class_name_4!.rule, local_class_name_4!.classSelector), + ).toMatchInlineSnapshot( `{ filePath: "/test/test.css", start: { line: 6, column: 8 }, end: { line: 6, column: 26 } }`, ); }); @@ -227,19 +269,44 @@ describe('getOriginalLocation', () => { + .with_newline_3, {} `), ); - expect(getOriginalLocation(with_newline_1!.rule, with_newline_1!.classSelector)).toMatchInlineSnapshot( + expect( + getOriginalLocationOfClassSelector(with_newline_1!.rule, with_newline_1!.classSelector), + ).toMatchInlineSnapshot( `{ filePath: "/test/test.css", start: { line: 1, column: 1 }, end: { line: 1, column: 15 } }`, ); - expect(getOriginalLocation(with_newline_2!.rule, with_newline_2!.classSelector)).toMatchInlineSnapshot( + expect( + getOriginalLocationOfClassSelector(with_newline_2!.rule, with_newline_2!.classSelector), + ).toMatchInlineSnapshot( `{ filePath: "/test/test.css", start: { line: 2, column: 1 }, end: { line: 2, column: 15 } }`, ); - expect(getOriginalLocation(with_newline_3!.rule, with_newline_3!.classSelector)).toMatchInlineSnapshot( + expect( + getOriginalLocationOfClassSelector(with_newline_3!.rule, with_newline_3!.classSelector), + ).toMatchInlineSnapshot( `{ filePath: "/test/test.css", start: { line: 3, column: 5 }, end: { line: 3, column: 19 } }`, ); }); }); +test('getOriginalLocationOfAtValue', () => { + function tryGetOriginalLocationOfAtValue(atValue: AtRule) { + const parsed = parseAtValue(atValue); + if (parsed.type === 'valueDeclaration') { + return getOriginalLocationOfAtValue(atValue, parsed); + } else { + throw new Error('Unexpected type'); + } + } + const [basic] = createAtValues( + createRoot(dedent` + @value basic: #000; + `), + ); + expect(tryGetOriginalLocationOfAtValue(basic!)).toMatchInlineSnapshot( + `{ filePath: "/test/test.css", start: { line: 1, column: 8 }, end: { line: 1, column: 13 } }`, + ); +}); + test('collectNodes', () => { const ast = createRoot(dedent` @import; @@ -277,3 +344,45 @@ test('parseAtImport', () => { expect(parseAtImport(atImports[3]!)).toBe('test.css'); expect(parseAtImport(atImports[4]!)).toBe('test.css'); }); + +test('parseAtValue', () => { + const atValues = createAtValues( + createRoot(dedent` + @value basic: #000; + @value withoutColon #000; + @value empty:; + @value comment:/* comment */; + @value complex: (max-width: 599px); + @value import from "test.css"; + @value import1, import2 from "test.css"; + @value import as alias from "test.css"; + /* + * NOTE: happy-css-modules intentionally does not support module specifier as variable. + * e.g. \`@value d, e from moduleName;\` + */ + `), + ); + expect(parseAtValue(atValues[0]!)).toStrictEqual({ type: 'valueDeclaration', tokenName: 'basic' }); + expect(parseAtValue(atValues[1]!)).toStrictEqual({ type: 'valueDeclaration', tokenName: 'withoutColon' }); + expect(parseAtValue(atValues[2]!)).toStrictEqual({ type: 'valueDeclaration', tokenName: 'empty' }); + expect(parseAtValue(atValues[3]!)).toStrictEqual({ type: 'valueDeclaration', tokenName: 'comment' }); + expect(parseAtValue(atValues[4]!)).toStrictEqual({ type: 'valueDeclaration', tokenName: 'complex' }); + expect(parseAtValue(atValues[5]!)).toStrictEqual({ + type: 'valueImportDeclaration', + imports: [{ importedTokenName: 'import', localTokenName: 'import' }], + from: 'test.css', + }); + expect(parseAtValue(atValues[6]!)).toStrictEqual({ + type: 'valueImportDeclaration', + imports: [ + { importedTokenName: 'import1', localTokenName: 'import1' }, + { importedTokenName: 'import2', localTokenName: 'import2' }, + ], + from: 'test.css', + }); + expect(parseAtValue(atValues[7]!)).toStrictEqual({ + type: 'valueImportDeclaration', + imports: [{ importedTokenName: 'import', localTokenName: 'alias' }], + from: 'test.css', + }); +}); diff --git a/packages/happy-css-modules/src/locator/postcss.ts b/packages/happy-css-modules/src/locator/postcss.ts index a718d0f..d291c8c 100644 --- a/packages/happy-css-modules/src/locator/postcss.ts +++ b/packages/happy-css-modules/src/locator/postcss.ts @@ -1,4 +1,4 @@ -import postcss, { type Rule, type AtRule, type Root, type Node, type Declaration, type Plugin } from 'postcss'; +import postcss, { type Rule, type AtRule, type Root, type Node } from 'postcss'; import modules from 'postcss-modules'; import selectorParser, { type ClassName } from 'postcss-selector-parser'; import valueParser from 'postcss-value-parser'; @@ -26,40 +26,27 @@ export type Location = end: undefined; }; -function removeDependenciesPlugin(): Plugin { - return { - postcssPlugin: 'remove-dependencies', - // eslint-disable-next-line @typescript-eslint/naming-convention - AtRule(atRule) { - if (isAtImportNode(atRule) || isAtValueNode(atRule)) { - atRule.remove(); - } - }, - // eslint-disable-next-line @typescript-eslint/naming-convention - Declaration(declaration) { - if (isComposesDeclaration(declaration)) { - declaration.remove(); - } - }, - }; -} - /** * Traverses a local token from the AST and returns its name. * @param ast The AST to traverse. * @returns The name of the local token. */ export async function generateLocalTokenNames(ast: Root): Promise { + class EmptyLoader { + async fetch(_file: string, _relativeTo: string, _depTrace: string): Promise<{ [key: string]: string }> { + // Return an empty object because we do not want to load external tokens in `generateLocalTokenNames`. + return Promise.resolve({}); + } + } return new Promise((resolve, reject) => { postcss .default() - // postcss-modules collects tokens (i.e., includes external tokens) by following - // the dependencies specified in the @import. - // However, we do not want `generateLocalTokenNames` to return external tokens. - // So we remove the @import beforehand. - .use(removeDependenciesPlugin()) .use( modules({ + // `@import`, `@value`, and `composes` can read tokens from external files. + // However, we want to collect only local tokens. So we will fake that + // an empty token is exported from the external file. + Loader: EmptyLoader, getJSON: (_cssFileName, json) => { resolve(Object.keys(json)); }, @@ -72,12 +59,12 @@ export async function generateLocalTokenNames(ast: Root): Promise { } /** - * Get the token's location on the source file. + * Get the original location of the class selector. * @param rule The rule node that contains the token. * @param classSelector The class selector node that contains the token. - * @returns The token's location on the source file. + * @returns The original location of the class selector. */ -export function getOriginalLocation(rule: Rule, classSelector: ClassName): Location { +export function getOriginalLocationOfClassSelector(rule: Rule, classSelector: ClassName): Location { // The node derived from `postcss.parse` always has `source` property. Therefore, this line is unreachable. if (rule.source === undefined || classSelector.source === undefined) throw new Error('Node#source is undefined'); // The node derived from `postcss.parse` always has `start` and `end` property. Therefore, this line is unreachable. @@ -127,6 +114,32 @@ export function getOriginalLocation(rule: Rule, classSelector: ClassName): Locat }; } +/** + * Get the original location of `@value`. + * @param atValue The `@value` rule. + * @returns The location of the `@value` rule. + */ +export function getOriginalLocationOfAtValue(atValue: AtRule, valueDeclaration: ValueDeclaration): Location { + // The node derived from `postcss.parse` always has `source` property. Therefore, this line is unreachable. + if (atValue.source === undefined) throw new Error('Node#source is undefined'); + // The node derived from `postcss.parse` always has `start` and `end` property. Therefore, this line is unreachable. + if (atValue.source.start === undefined) throw new Error('Node#start is undefined'); + if (atValue.source.end === undefined) throw new Error('Node#end is undefined'); + if (atValue.source.input.file === undefined) throw new Error('Node#input.file is undefined'); + + return { + filePath: atValue.source.input.file, + start: { + line: atValue.source.start.line, + column: atValue.source.start.column + 7, // Add for `@value ` + }, + end: { + line: atValue.source.start.line, + column: atValue.source.start.column + 7 + valueDeclaration.tokenName.length, // Add for `@value ` and token name + }, + }; +} + function isAtRuleNode(node: Node): node is AtRule { return node.type === 'atrule'; } @@ -143,16 +156,9 @@ function isRuleNode(node: Node): node is Rule { return node.type === 'rule'; } -function isDeclaration(node: Node): node is Declaration { - return node.type === 'decl'; -} - -function isComposesDeclaration(node: Node): node is Declaration { - return isDeclaration(node) && node.prop === 'composes'; -} - type CollectNodesResult = { atImports: AtRule[]; + atValues: AtRule[]; classSelectors: { rule: Rule; classSelector: ClassName }[]; }; @@ -162,10 +168,13 @@ type CollectNodesResult = { */ export function collectNodes(ast: Root): CollectNodesResult { const atImports: AtRule[] = []; + const atValues: AtRule[] = []; const classSelectors: { rule: Rule; classSelector: ClassName }[] = []; ast.walk((node) => { if (isAtImportNode(node)) { atImports.push(node); + } else if (isAtValueNode(node)) { + atValues.push(node); } else if (isRuleNode(node)) { // In `rule.selector` comes the following string: // 1. ".foo" @@ -183,7 +192,7 @@ export function collectNodes(ast: Root): CollectNodesResult { }).processSync(node); } }); - return { atImports, classSelectors }; + return { atImports, atValues, classSelectors }; } /** @@ -202,3 +211,78 @@ export function parseAtImport(atImport: AtRule): string | undefined { } return undefined; } + +type ValueDeclaration = { + type: 'valueDeclaration'; + tokenName: string; + // value: string; // unneeded +}; +type ValueImportDeclaration = { + type: 'valueImportDeclaration'; + imports: { importedTokenName: string; localTokenName: string }[]; + from: string; +}; + +type ParsedAtValue = ValueDeclaration | ValueImportDeclaration; + +const matchImports = /^(.+?|\([\s\S]+?\))\s+from\s+("[^"]*"|'[^']*'|[\w-]+)$/u; +const matchValueDefinition = /(?:\s+|^)([\w-]+):?(.*?)$/u; +const matchImport = /^([\w-]+)(?:\s+as\s+([\w-]+))?/u; + +/** + * Parse the `@value` rule. + * Forked from https://github.com/css-modules/postcss-modules-values/blob/v4.0.0/src/index.js. + * + * @license + * ISC License (ISC) + * Copyright (c) 2015, Glen Maddern + * + * Permission to use, copy, modify, and/or distribute this software for any purpose with or without fee is hereby granted, + * provided that the above copyright notice and this permission notice appear in all copies. + * + * THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH REGARD TO THIS SOFTWARE INCLUDING + * ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, + * INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, + * WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH + * THE USE OR PERFORMANCE OF THIS SOFTWARE. + */ +export function parseAtValue(atValue: AtRule): ParsedAtValue { + const matchesForImports = atValue.params.match(matchImports); + if (matchesForImports) { + const [, aliases, path] = matchesForImports; + + // eslint-disable-next-line @typescript-eslint/restrict-template-expressions + if (aliases === undefined || path === undefined) throw new Error(`unreachable`); + + const imports = aliases + .replace(/^\(\s*([\s\S]+)\s*\)$/u, '$1') + .split(/\s*,\s*/u) + .map((alias) => { + const tokens = matchImport.exec(alias); + + if (tokens) { + const [, theirName, myName] = tokens; + if (theirName === undefined) throw new Error(`unreachable`); + return { importedTokenName: theirName, localTokenName: myName ?? theirName }; + } else { + throw new Error(`@import statement "${alias}" is invalid!`); + } + }); + + // Remove quotes from the path. + // NOTE: This is a restriction unique to "happy-css-modules" and not a specification of CSS Modules. + const normalizedPath = path.replace(/^['"]|['"]$/gu, ''); + + return { type: 'valueImportDeclaration', imports, from: normalizedPath }; + } + + const matchesForValueDefinitions = `${atValue.params}${atValue.raws.between!}`.match(matchValueDefinition); + if (matchesForValueDefinitions) { + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const [, key, value] = matchesForValueDefinitions; + if (key === undefined) throw new Error(`unreachable`); + return { type: 'valueDeclaration', tokenName: key }; + } + // eslint-disable-next-line @typescript-eslint/restrict-template-expressions + throw new Error(`@value statement "${atValue.source!}" is invalid!`); +} diff --git a/packages/happy-css-modules/src/test-util/util.ts b/packages/happy-css-modules/src/test-util/util.ts index 1ce81e4..ee439e4 100644 --- a/packages/happy-css-modules/src/test-util/util.ts +++ b/packages/happy-css-modules/src/test-util/util.ts @@ -21,6 +21,10 @@ export function createAtImports(root: Root): AtRule[] { return collectNodes(root).atImports; } +export function createAtValues(root: Root): AtRule[] { + return collectNodes(root).atValues; +} + export function createClassSelectors(root: Root): { rule: Rule; classSelector: ClassName }[] { return collectNodes(root).classSelectors; }