diff --git a/jest.setup.ts b/jest.setup.ts index e020494..b0368c2 100644 --- a/jest.setup.ts +++ b/jest.setup.ts @@ -12,8 +12,8 @@ const jsonSerializer: jest.SnapshotSerializerPlugin = { test(val) { const isLoadResult = val && - Object.prototype.hasOwnProperty.call(val, 'tokens') && - Object.prototype.hasOwnProperty.call(val, 'dependencies'); + Object.prototype.hasOwnProperty.call(val, 'tokenInfos') && + Object.prototype.hasOwnProperty.call(val, 'transpileDependencies'); const isLocation = val && Object.prototype.hasOwnProperty.call(val, 'filePath') && diff --git a/packages/happy-css-modules/src/emitter/dts.test.ts b/packages/happy-css-modules/src/emitter/dts.test.ts index bf012c2..d7e16b1 100644 --- a/packages/happy-css-modules/src/emitter/dts.test.ts +++ b/packages/happy-css-modules/src/emitter/dts.test.ts @@ -2,6 +2,7 @@ import dedent from 'dedent'; import { SourceMapConsumer } from 'source-map'; import { Locator } from '../locator/index.js'; import { getFixturePath, createFixtures } from '../test-util/util.js'; +import { createDefaultTransformer } from '../transformer/index.js'; import { generateDtsContentWithSourceMap, getDtsFilePath } from './dts.js'; import { type DtsFormatOptions } from './index.js'; @@ -43,14 +44,13 @@ describe('generateDtsContentWithSourceMap', () => { filePath, dtsFilePath, sourceMapFilePath, - result.tokens, + result.tokenInfos, dtsFormatOptions, isExternalFile, ); expect(dtsContent).toMatchInlineSnapshot(` "declare const styles: - & Readonly> - & Readonly> + & Readonly & Readonly<{ "a": string }> & Readonly<{ "b": string }> & Readonly<{ "b": string }> @@ -59,7 +59,7 @@ describe('generateDtsContentWithSourceMap', () => { " `); const smc = await new SourceMapConsumer(sourceMap.toJSON()); - expect(smc.originalPositionFor({ line: 4, column: 15 })).toMatchInlineSnapshot(` + expect(smc.originalPositionFor({ line: 3, column: 15 })).toMatchInlineSnapshot(` { "column": 0, "line": 2, @@ -67,7 +67,7 @@ describe('generateDtsContentWithSourceMap', () => { "source": "1.css", } `); - expect(smc.originalPositionFor({ line: 5, column: 15 })).toMatchInlineSnapshot(` + expect(smc.originalPositionFor({ line: 4, column: 15 })).toMatchInlineSnapshot(` { "column": 0, "line": 3, @@ -75,7 +75,7 @@ describe('generateDtsContentWithSourceMap', () => { "source": "1.css", } `); - expect(smc.originalPositionFor({ line: 6, column: 15 })).toMatchInlineSnapshot(` + expect(smc.originalPositionFor({ line: 5, column: 15 })).toMatchInlineSnapshot(` { "column": 0, "line": 4, @@ -100,7 +100,7 @@ describe('generateDtsContentWithSourceMap', () => { filePath, dtsFilePath, sourceMapFilePath, - result.tokens, + result.tokenInfos, { ...dtsFormatOptions, localsConvention: undefined, @@ -122,7 +122,7 @@ describe('generateDtsContentWithSourceMap', () => { filePath, dtsFilePath, sourceMapFilePath, - result.tokens, + result.tokenInfos, { ...dtsFormatOptions, localsConvention: 'camelCaseOnly', @@ -144,7 +144,7 @@ describe('generateDtsContentWithSourceMap', () => { filePath, dtsFilePath, sourceMapFilePath, - result.tokens, + result.tokenInfos, { ...dtsFormatOptions, localsConvention: 'camelCase', @@ -168,7 +168,7 @@ describe('generateDtsContentWithSourceMap', () => { filePath, dtsFilePath, sourceMapFilePath, - result.tokens, + result.tokenInfos, { ...dtsFormatOptions, localsConvention: 'dashesOnly', @@ -190,7 +190,7 @@ describe('generateDtsContentWithSourceMap', () => { filePath, dtsFilePath, sourceMapFilePath, - result.tokens, + result.tokenInfos, { ...dtsFormatOptions, localsConvention: 'dashes', @@ -218,7 +218,7 @@ describe('generateDtsContentWithSourceMap', () => { getFixturePath('/test/src/1.css'), getFixturePath('/test/dist/1.css.d.ts'), getFixturePath('/test/dist/1.css.d.ts.map'), - result.tokens, + result.tokenInfos, dtsFormatOptions, isExternalFile, ); @@ -232,7 +232,7 @@ describe('generateDtsContentWithSourceMap', () => { expect(sourceMap.toJSON().sources).toStrictEqual(['../src/1.css']); expect(sourceMap.toJSON().file).toStrictEqual('1.css.d.ts'); }); - test('treats imported tokens from external files the same as local tokens', async () => { + test('removes imported tokens from external files with @import', async () => { createFixtures({ '/test/1.css': dedent` @import './2.css'; @@ -247,13 +247,45 @@ describe('generateDtsContentWithSourceMap', () => { filePath, dtsFilePath, sourceMapFilePath, - result.tokens, + result.tokenInfos, dtsFormatOptions, (filePath) => filePath.endsWith('3.css'), ); expect(dtsContent).toMatchInlineSnapshot(` "declare const styles: - & Readonly> + & Readonly + & Readonly<{ "a": string }> + ; + export default styles; + " + `); + }); + test('treats sass imported tokens from external files the same as local tokens', async () => { + const locator = new Locator({ transformer: createDefaultTransformer() }); + createFixtures({ + '/test/1.scss': dedent` + @import './2.scss'; + @import './3.scss'; + .a { dummy: ''; } + `, + '/test/2.scss': `.b { dummy: ''; }`, + '/test/3.scss': `.c { dummy: ''; }`, + }); + const filePath = getFixturePath('/test/1.scss'); + const dtsFilePath = getFixturePath('/test/1.scss.d.ts'); + const sourceMapFilePath = getFixturePath('/test/1.scss.map'); + const result = await locator.load(filePath); + const { dtsContent } = generateDtsContentWithSourceMap( + filePath, + dtsFilePath, + sourceMapFilePath, + result.tokenInfos, + dtsFormatOptions, + (filePath) => filePath.endsWith('3.scss'), + ); + expect(dtsContent).toMatchInlineSnapshot(` + "declare const styles: + & Readonly> & Readonly<{ "c": string }> & Readonly<{ "a": string }> ; diff --git a/packages/happy-css-modules/src/emitter/dts.ts b/packages/happy-css-modules/src/emitter/dts.ts index 74ec7d0..3958e72 100644 --- a/packages/happy-css-modules/src/emitter/dts.ts +++ b/packages/happy-css-modules/src/emitter/dts.ts @@ -2,7 +2,7 @@ import { EOL } from 'os'; import { basename, parse, join } from 'path'; import camelcase from 'camelcase'; import { SourceNode, type CodeWithSourceMap } from '../library/source-map/index.js'; -import { type Token } from '../locator/index.js'; +import type { ImportedAllTokensFromModule, LocalToken, TokenInfo } from '../locator/index.js'; import { type LocalsConvention } from '../runner.js'; import { getRelativePath, type DtsFormatOptions } from './index.js'; @@ -27,75 +27,114 @@ function dashesCamelCase(str: string): string { }); } -function formatTokens(tokens: Token[], localsConvention: LocalsConvention): Token[] { - const result: Token[] = []; - for (const token of tokens) { - if (localsConvention === 'camelCaseOnly') { - result.push({ ...token, name: camelcase(token.name) }); - } else if (localsConvention === 'camelCase') { - result.push(token); - result.push({ ...token, name: camelcase(token.name) }); - } else if (localsConvention === 'dashesOnly') { - result.push({ ...token, name: dashesCamelCase(token.name) }); - } else if (localsConvention === 'dashes') { - result.push(token); - result.push({ ...token, name: dashesCamelCase(token.name) }); - } else { - result.push(token); // asIs - } +function formatLocalToken(localToken: LocalToken, localsConvention: LocalsConvention): string[] { + const result: string[] = []; + if (localsConvention === 'camelCaseOnly') { + result.push(camelcase(localToken.name)); + } else if (localsConvention === 'camelCase') { + result.push(localToken.name); + result.push(camelcase(localToken.name)); + } else if (localsConvention === 'dashesOnly') { + result.push(dashesCamelCase(localToken.name)); + } else if (localsConvention === 'dashes') { + result.push(localToken.name); + result.push(dashesCamelCase(localToken.name)); + } else { + result.push(localToken.name); // asIs } return result; } -function generateTokenDeclarations( +function generateTokenDeclarationsForLocalToken( filePath: string, sourceMapFilePath: string, - tokens: Token[], + localToken: LocalToken, dtsFormatOptions: DtsFormatOptions | undefined, isExternalFile: (filePath: string) => boolean, ): (typeof SourceNode)[] { - const formattedTokens = formatTokens(tokens, dtsFormatOptions?.localsConvention); const result: (typeof SourceNode)[] = []; - for (const token of formattedTokens) { - // Only one original position can be associated with one generated position. - // This is due to the sourcemap specification. Therefore, we output multiple type definitions - // with the same name and assign a separate original position to each. + // Only one original position can be associated with one generated position. + // This is due to the sourcemap specification. Therefore, we output multiple type definitions + // with the same name and assign a separate original position to each. + const formattedTokenNames = formatLocalToken(localToken, dtsFormatOptions?.localsConvention); + for (const formattedTokenName of formattedTokenNames) { + let originalLocation = localToken.originalLocation; + if (originalLocation.filePath === undefined) { + // If the original location is not specified, fallback to the source file. + originalLocation = { + filePath, + start: { line: 1, column: 1 }, + end: { line: 1, column: 1 }, + }; + } - for (let originalLocation of token.originalLocations) { - if (originalLocation.filePath === undefined) { - // If the original location is not specified, fallback to the source file. - originalLocation = { - filePath, - start: { line: 1, column: 1 }, - end: { line: 1, column: 1 }, - }; - } + result.push( + originalLocation.filePath === filePath || isExternalFile(originalLocation.filePath) + ? 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), + `"${formattedTokenName}"`, + formattedTokenName, + ), + ': string }>', + ]) + : // 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, [ + '& Readonly>', + ]), + ); + } + return result; +} + +function generateTokenDeclarationForImportedAllTokensFromModule( + filePath: string, + importedAllTokensFromModule: ImportedAllTokensFromModule, +): typeof SourceNode { + return new SourceNode(null, null, null, [ + '& Readonly', + ]); +} + +function generateTokenDeclarations( + filePath: string, + sourceMapFilePath: string, + tokenInfos: TokenInfo[], + dtsFormatOptions: DtsFormatOptions | undefined, + isExternalFile: (filePath: string) => boolean, +): (typeof SourceNode)[] { + const result: (typeof SourceNode)[] = []; + for (const tokenInfo of tokenInfos) { + if (tokenInfo.type === 'localToken') { result.push( - originalLocation.filePath === filePath || isExternalFile(originalLocation.filePath) - ? 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, - ), - ': string }>', - ]) - : // 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, [ - '& Readonly>', - ]), + ...generateTokenDeclarationsForLocalToken( + filePath, + sourceMapFilePath, + tokenInfo, + dtsFormatOptions, + isExternalFile, + ), ); + } else if (tokenInfo.type === 'importedAllTokensFromModule') { + if (!isExternalFile(tokenInfo.filePath)) { + result.push(generateTokenDeclarationForImportedAllTokensFromModule(filePath, tokenInfo)); + } + } else { + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const _: never = tokenInfo; } } return result; @@ -106,14 +145,14 @@ export function generateDtsContentWithSourceMap( filePath: string, dtsFilePath: string, sourceMapFilePath: string, - tokens: Token[], + tokenInfos: TokenInfo[], dtsFormatOptions: DtsFormatOptions | undefined, isExternalFile: (filePath: string) => boolean, ): { dtsContent: CodeWithSourceMap['code']; sourceMap: CodeWithSourceMap['map'] } { const tokenDeclarations = generateTokenDeclarations( filePath, sourceMapFilePath, - tokens, + tokenInfos, dtsFormatOptions, isExternalFile, ); diff --git a/packages/happy-css-modules/src/emitter/index.test.ts b/packages/happy-css-modules/src/emitter/index.test.ts index 0ebfc74..a889000 100644 --- a/packages/happy-css-modules/src/emitter/index.test.ts +++ b/packages/happy-css-modules/src/emitter/index.test.ts @@ -56,12 +56,12 @@ describe('emitGeneratedFiles', () => { }); test('skips writing to disk if the generated files are the same', async () => { const tokens1 = [fakeToken({ name: 'foo', originalLocations: [{ start: { line: 1, column: 1 } }] })]; - await emitGeneratedFiles({ ...defaultArgs, tokens: tokens1 }); + await emitGeneratedFiles({ ...defaultArgs, tokenInfos: tokens1 }); const mtimeForDts1 = (await stat(getFixturePath('/test/1.css.d.ts'))).mtime; const mtimeForSourceMap1 = (await stat(getFixturePath('/test/1.css.d.ts.map'))).mtime; await waitForAsyncTask(1); // so that mtime changes. - await emitGeneratedFiles({ ...defaultArgs, tokens: tokens1 }); + await emitGeneratedFiles({ ...defaultArgs, tokenInfos: tokens1 }); const mtimeForDts2 = (await stat(getFixturePath('/test/1.css.d.ts'))).mtime; const mtimeForSourceMap2 = (await stat(getFixturePath('/test/1.css.d.ts.map'))).mtime; expect(mtimeForDts1).toEqual(mtimeForDts2); // skipped @@ -69,7 +69,7 @@ describe('emitGeneratedFiles', () => { await waitForAsyncTask(1); // so that mtime changes. const tokens2 = [fakeToken({ name: 'bar', originalLocations: [{ start: { line: 1, column: 1 } }] })]; - await emitGeneratedFiles({ ...defaultArgs, tokens: tokens2 }); + await emitGeneratedFiles({ ...defaultArgs, tokenInfos: tokens2 }); const mtimeForDts3 = (await stat(getFixturePath('/test/1.css.d.ts'))).mtime; const mtimeForSourceMap3 = (await stat(getFixturePath('/test/1.css.d.ts.map'))).mtime; expect(mtimeForDts1).not.toEqual(mtimeForDts3); // not skipped diff --git a/packages/happy-css-modules/src/emitter/index.ts b/packages/happy-css-modules/src/emitter/index.ts index 7828945..e8a0736 100644 --- a/packages/happy-css-modules/src/emitter/index.ts +++ b/packages/happy-css-modules/src/emitter/index.ts @@ -1,6 +1,6 @@ import { dirname, isAbsolute, relative } from 'path'; import { DEFAULT_ARBITRARY_EXTENSIONS } from '../config.js'; -import { type Token } from '../locator/index.js'; +import { type TokenInfo } from '../locator/index.js'; import { type LocalsConvention } from '../runner.js'; import { exists } from '../util.js'; import { generateDtsContentWithSourceMap, getDtsFilePath } from './dts.js'; @@ -37,8 +37,8 @@ export type DtsFormatOptions = { export type EmitterOptions = { /** The path to the source file (i.e. `/dir/foo.css`). It is absolute. */ filePath: string; - /** The tokens exported by the source file. */ - tokens: Token[]; + /** The information of tokens exported by the source file. */ + tokenInfos: TokenInfo[]; /** Whether to output declaration map (i.e. `/dir/foo.css.d.ts.map`) or not. */ emitDeclarationMap: boolean | undefined; /** The options for formatting the type definition. */ @@ -49,7 +49,7 @@ export type EmitterOptions = { export async function emitGeneratedFiles({ filePath, - tokens, + tokenInfos, emitDeclarationMap, dtsFormatOptions, isExternalFile, @@ -61,7 +61,7 @@ export async function emitGeneratedFiles({ filePath, dtsFilePath, sourceMapFilePath, - tokens, + tokenInfos, dtsFormatOptions, isExternalFile, ); diff --git a/packages/happy-css-modules/src/locator/index.test.ts b/packages/happy-css-modules/src/locator/index.test.ts index 874a57d..962b0f5 100644 --- a/packages/happy-css-modules/src/locator/index.test.ts +++ b/packages/happy-css-modules/src/locator/index.test.ts @@ -1,10 +1,7 @@ -import { readFile, writeFile } from 'fs/promises'; import { randomUUID } from 'node:crypto'; -import { jest } from '@jest/globals'; import dedent from 'dedent'; import { Locator, createDefaultTransformer } from '../index.js'; import { createFixtures, getFixturePath } from '../test-util/util.js'; -import { sleepSync } from '../util.js'; const locator = new Locator(); @@ -18,19 +15,25 @@ test('basic', async () => { const result = await locator.load(getFixturePath('/test/1.css')); expect(result).toMatchInlineSnapshot(` { - dependencies: [], - tokens: [ + transpileDependencies: [], + tokenInfos: [ { + type: "localToken", name: "a", - originalLocations: [ - { filePath: "/test/1.css", start: { line: 1, column: 1 }, end: { line: 1, column: 2 } }, - ], + originalLocation: { + filePath: "/test/1.css", + start: { line: 1, column: 1 }, + end: { line: 1, column: 2 }, + }, }, { + type: "localToken", name: "b", - originalLocations: [ - { filePath: "/test/1.css", start: { line: 2, column: 1 }, end: { line: 2, column: 2 } }, - ], + originalLocation: { + filePath: "/test/1.css", + start: { line: 2, column: 1 }, + end: { line: 2, column: 2 }, + }, }, ], } @@ -64,38 +67,12 @@ test('tracks other files when `@import` is present', async () => { const result = await locator.load(getFixturePath('/test/1.css')); expect(result).toMatchInlineSnapshot(` { - dependencies: [ - "/test/2.css", - "/test/3.css", - "/test/4.css", - "/test/5.css", - "/test/5-recursive.css", - ], - tokens: [ - { - name: "a", - originalLocations: [ - { filePath: "/test/2.css", start: { line: 1, column: 1 }, end: { line: 1, column: 2 } }, - ], - }, - { - name: "b", - originalLocations: [ - { filePath: "/test/3.css", start: { line: 1, column: 1 }, end: { line: 1, column: 2 } }, - ], - }, - { - name: "c", - originalLocations: [ - { filePath: "/test/4.css", start: { line: 1, column: 1 }, end: { line: 1, column: 2 } }, - ], - }, - { - name: "d", - originalLocations: [ - { filePath: "/test/5-recursive.css", start: { line: 1, column: 1 }, end: { line: 1, column: 2 } }, - ], - }, + transpileDependencies: [], + tokenInfos: [ + { type: "importedAllTokensFromModule", filePath: "/test/2.css" }, + { type: "importedAllTokensFromModule", filePath: "/test/3.css" }, + { type: "importedAllTokensFromModule", filePath: "/test/4.css" }, + { type: "importedAllTokensFromModule", filePath: "/test/5.css" }, ], } `); @@ -116,13 +93,16 @@ test('does not track other files by `composes`', async () => { const result = await locator.load(getFixturePath('/test/1.css')); expect(result).toMatchInlineSnapshot(` { - dependencies: [], - tokens: [ + transpileDependencies: [], + tokenInfos: [ { + type: "localToken", name: "a", - originalLocations: [ - { filePath: "/test/1.css", start: { line: 1, column: 1 }, end: { line: 1, column: 2 } }, - ], + originalLocation: { + filePath: "/test/1.css", + start: { line: 1, column: 1 }, + end: { line: 1, column: 2 }, + }, }, ], } @@ -149,69 +129,33 @@ test('normalizes tokens', async () => { const result = await locator.load(getFixturePath('/test/1.css')); expect(result).toMatchInlineSnapshot(` { - dependencies: ["/test/2.css"], - tokens: [ + transpileDependencies: [], + tokenInfos: [ + { type: "importedAllTokensFromModule", filePath: "/test/2.css" }, + { type: "importedAllTokensFromModule", filePath: "/test/2.css" }, { + type: "localToken", name: "a", - originalLocations: [ - { filePath: "/test/2.css", start: { line: 1, column: 1 }, end: { line: 1, column: 2 } }, - { filePath: "/test/1.css", start: { line: 4, column: 1 }, end: { line: 4, column: 2 } }, - { filePath: "/test/1.css", start: { line: 5, column: 1 }, end: { line: 5, column: 2 } }, - ], + originalLocation: { + filePath: "/test/1.css", + start: { line: 4, column: 1 }, + end: { line: 4, column: 2 }, + }, }, { - name: "b", - originalLocations: [ - { filePath: "/test/2.css", start: { line: 2, column: 1 }, end: { line: 2, column: 2 } }, - ], + type: "localToken", + name: "a", + originalLocation: { + filePath: "/test/1.css", + start: { line: 5, column: 1 }, + end: { line: 5, column: 2 }, + }, }, ], } `); }); -test('returns the result from the cache when the file has not been modified', async () => { - createFixtures({ - '/test/1.css': dedent` - @import './2.css'; - @import './3.css'; - `, - '/test/2.css': dedent` - .b {} - `, - '/test/3.css': dedent` - .c {} - .d {} - `, - }); - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const readCSSSpy = jest.spyOn(locator, 'readCSS' as any); - await locator.load(getFixturePath('/test/1.css')); - expect(readCSSSpy).toHaveBeenCalledTimes(3); - expect(readCSSSpy).toHaveBeenNthCalledWith(1, getFixturePath('/test/1.css')); - expect(readCSSSpy).toHaveBeenNthCalledWith(2, getFixturePath('/test/2.css')); - expect(readCSSSpy).toHaveBeenNthCalledWith(3, getFixturePath('/test/3.css')); - readCSSSpy.mockClear(); - - // update `/test/2.css` - sleepSync(1); // wait for the file system to update the mtime - await writeFile(getFixturePath('/test/2.css'), await readFile(getFixturePath('/test/2.css'), 'utf-8')); - - // `3.css` is not updated, so the cache is used. Therefore, `readFile` is not called. - await locator.load(getFixturePath('/test/3.css')); - expect(readCSSSpy).toHaveBeenCalledTimes(0); - - // `1.css` is not updated, but dependencies are updated, so the cache is used. Therefore, `readFile` is called. - await locator.load(getFixturePath('/test/1.css')); - expect(readCSSSpy).toHaveBeenCalledTimes(2); - expect(readCSSSpy).toHaveBeenNthCalledWith(1, getFixturePath('/test/1.css')); - expect(readCSSSpy).toHaveBeenNthCalledWith(2, getFixturePath('/test/2.css')); - - // ``2.css` is updated, but the cache is already available because it was updated in the previous step. Therefore, `readFile` is not called. - await locator.load(getFixturePath('/test/2.css')); - expect(readCSSSpy).toHaveBeenCalledTimes(2); -}); - describe('supports sourcemap', () => { test('restores original locations from sourcemap', async () => { const transformer = createDefaultTransformer(); @@ -229,20 +173,34 @@ describe('supports sourcemap', () => { const result = await locator.load(getFixturePath('/test/1.scss')); expect(result).toMatchInlineSnapshot(` { - dependencies: [], - tokens: [ + transpileDependencies: [], + tokenInfos: [ + { + type: "localToken", + name: "nesting", + originalLocation: { + filePath: "/test/1.scss", + start: { line: 1, column: 1 }, + end: { line: 1, column: 8 }, + }, + }, { + type: "localToken", name: "nesting", - originalLocations: [ - { filePath: "/test/1.scss", start: { line: 1, column: 1 }, end: { line: 1, column: 8 } }, - { filePath: "/test/1.scss", start: { line: 3, column: 3 }, end: { line: 3, column: 10 } }, - ], + originalLocation: { + filePath: "/test/1.scss", + start: { line: 3, column: 3 }, + end: { line: 3, column: 10 }, + }, }, { + type: "localToken", name: "nesting_child", - originalLocations: [ - { filePath: "/test/1.scss", start: { line: 3, column: 3 }, end: { line: 3, column: 16 } }, - ], + originalLocation: { + filePath: "/test/1.scss", + start: { line: 3, column: 3 }, + end: { line: 3, column: 16 }, + }, }, ], } @@ -268,22 +226,28 @@ describe('supports sourcemap', () => { const result = await locator.load(getFixturePath('/test/1.css')); expect(result).toMatchInlineSnapshot(` { - dependencies: [], - tokens: [ + transpileDependencies: [], + tokenInfos: [ { + type: "localToken", name: "selector_list_a_1", - originalLocations: [ - { filePath: "/test/1.css", start: { line: 1, column: 1 }, end: { line: 1, column: 18 } }, - ], + originalLocation: { + filePath: "/test/1.css", + start: { line: 1, column: 1 }, + end: { line: 1, column: 18 }, + }, }, { + type: "localToken", name: "selector_list_a_2", - originalLocations: [ - { filePath: "/test/1.css", start: { line: 1, column: 1 }, end: { line: 1, column: 18 } }, - ], + originalLocation: { + filePath: "/test/1.css", + start: { line: 1, column: 1 }, + end: { line: 1, column: 18 }, + }, }, - { name: "selector_list_b_1", originalLocations: [{}] }, - { name: "selector_list_b_2", originalLocations: [{}] }, + { type: "localToken", name: "selector_list_b_1", originalLocation: {} }, + { type: "localToken", name: "selector_list_b_2", originalLocation: {} }, ], } `); @@ -298,14 +262,5 @@ test('ignores http(s) protocol file', async () => { `, }); const result = await locator.load(getFixturePath('/test/1.css')); - expect(result.dependencies).toStrictEqual([]); -}); - -test('block concurrent calls to load method', async () => { - createFixtures({ - '/test/1.css': `.a {}`, - }); - await expect(async () => { - await Promise.all([locator.load(getFixturePath('/test/1.css')), locator.load(getFixturePath('/test/1.css'))]); - }).rejects.toThrowError('Cannot call `Locator#load` concurrently.'); + expect(result).toStrictEqual({ tokenInfos: [], transpileDependencies: [] }); }); diff --git a/packages/happy-css-modules/src/locator/index.ts b/packages/happy-css-modules/src/locator/index.ts index 25af115..a5a4b2f 100644 --- a/packages/happy-css-modules/src/locator/index.ts +++ b/packages/happy-css-modules/src/locator/index.ts @@ -3,7 +3,7 @@ import postcss from 'postcss'; 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 { unique } from '../util.js'; import { getOriginalLocation, generateLocalTokenNames, parseAtImport, type Location, collectNodes } from './postcss.js'; export { collectNodes, type Location } from './postcss.js'; @@ -16,14 +16,30 @@ function isIgnoredSpecifier(specifier: string): boolean { return specifier.startsWith('http://') || specifier.startsWith('https://'); } -/** The exported token. */ -export type Token = { - /** The token name. */ +/** + * The token defined in the file. + * @example 'class' of `.class {}` + * @example 'val' of `@value val: #000;` + */ +export type LocalToken = { + type: 'localToken'; name: string; - /** The original locations of the token in the source file. */ - originalLocations: Location[]; + /** The original location of the token in the source file. */ + originalLocation: Location; +}; + +/** + * The all tokens imported from other CSS Modules files. + * @example `@import './file.css';`. + */ +export type ImportedAllTokensFromModule = { + type: 'importedAllTokensFromModule'; + filePath: string; }; +/** The exported token info. */ +export type TokenInfo = LocalToken | ImportedAllTokensFromModule; + type CacheEntry = { mtime: number; // TODO: `--cache-strategy` option will allow you to switch between `content` and `metadata` modes. result: LoadResult; @@ -31,28 +47,12 @@ type CacheEntry = { /** The result of `Locator#load`. */ export type LoadResult = { - /** The path of the file imported from the source file with `@import`. */ - dependencies: string[]; - /** The tokens exported by the source file. */ - tokens: Token[]; + /** The information of the exported tokens from CSS Modules files. */ + tokenInfos: TokenInfo[]; + /** The path to the dependent files needed to transpile that file. */ + transpileDependencies: string[]; }; -function normalizeTokens(tokens: Token[]): Token[] { - const tokenNameToOriginalLocations = new Map(); - for (const token of tokens) { - tokenNameToOriginalLocations.set( - token.name, - uniqueBy([...(tokenNameToOriginalLocations.get(token.name) ?? []), ...token.originalLocations], (location) => - JSON.stringify(location), - ), - ); - } - return Array.from(tokenNameToOriginalLocations.entries()).map(([name, originalLocations]) => ({ - name, - originalLocations, - })); -} - export type LocatorOptions = { /** The function to transform source code. */ transformer?: Transformer | undefined; @@ -68,7 +68,6 @@ export class Locator { private readonly cache: Map = new Map(); private readonly transformer: Transformer | undefined; private readonly resolver: StrictlyResolver; - private loading = false; constructor(options?: LocatorOptions) { this.transformer = options?.transformer ?? createDefaultTransformer(); @@ -80,24 +79,6 @@ export class Locator { }; } - /** Returns `true` if the cache is outdated. */ - private async isCacheOutdated(filePath: string): Promise { - const entry = this.cache.get(filePath); - if (!entry) return true; - const mtime = (await stat(filePath)).mtime.getTime(); - if (entry.mtime !== mtime) return true; - - const { dependencies } = entry.result; - for (const dependency of dependencies) { - const entry = this.cache.get(dependency); - if (!entry) return true; - // eslint-disable-next-line no-await-in-loop - const mtime = (await stat(dependency)).mtime.getTime(); - if (entry.mtime !== mtime) return true; - } - return false; - } - /** * Reads the source file and returns the code. * If transformer is specified, the code is transformed before returning. @@ -132,23 +113,9 @@ export class Locator { /** Returns information about the tokens exported from the CSS Modules file. */ async load(filePath: string): Promise { - if (this.loading) throw new Error('Cannot call `Locator#load` concurrently.'); - this.loading = true; - const result = await this._load(filePath).finally(() => { - this.loading = false; - }); - return result; - } - - private async _load(filePath: string): Promise { - if (!(await this.isCacheOutdated(filePath))) { - const cacheEntry = this.cache.get(filePath)!; - return cacheEntry.result; - } - const mtime = (await stat(filePath)).mtime.getTime(); - const { css, map, dependencies } = await this.readCSS(filePath); + const { css, map, dependencies: transpileDependencies } = await this.readCSS(filePath); const ast = postcss.parse(css, { from: filePath, map: map ? { inline: false, prev: map } : { inline: false } }); @@ -156,24 +123,24 @@ export class Locator { // The tokens are fetched using `postcss-modules` plugin. const localTokenNames = await generateLocalTokenNames(ast); - const tokens: Token[] = []; + const tokenInfos: TokenInfo[] = []; const { atImports, classSelectors } = collectNodes(ast); - // Load imported sheets recursively. + // Handle `@import`. for (const atImport of atImports) { const importedSheetPath = parseAtImport(atImport); if (!importedSheetPath) continue; if (isIgnoredSpecifier(importedSheetPath)) continue; // eslint-disable-next-line no-await-in-loop const from = await this.resolver(importedSheetPath, { request: filePath }); - // eslint-disable-next-line no-await-in-loop - const result = await this._load(from); - const externalTokens = result.tokens; - dependencies.push(from, ...result.dependencies); - tokens.push(...externalTokens); + tokenInfos.push({ + type: 'importedAllTokensFromModule', + filePath: from, + }); } + // Handle `.class {}` and `@value val: #000;`. // Traverse the source file to find a class selector that matches the local token. for (const { rule, classSelector } of classSelectors) { // Consider a class selector to be the origin of a token if it matches a token fetched by postcss-modules. @@ -182,15 +149,16 @@ export class Locator { const originalLocation = getOriginalLocation(rule, classSelector); - tokens.push({ + tokenInfos.push({ + type: 'localToken', name: classSelector.value, - originalLocations: [originalLocation], + originalLocation, }); } const result: LoadResult = { - dependencies: unique(dependencies).filter((dep) => dep !== filePath), - tokens: normalizeTokens(tokens), + transpileDependencies: unique(transpileDependencies).filter((dep) => dep !== filePath), + tokenInfos, }; this.cache.set(filePath, { mtime, result }); return result; diff --git a/packages/happy-css-modules/src/runner.test.ts b/packages/happy-css-modules/src/runner.test.ts index d662844..00cd667 100644 --- a/packages/happy-css-modules/src/runner.test.ts +++ b/packages/happy-css-modules/src/runner.test.ts @@ -258,15 +258,14 @@ describe('handles external files', () => { test('treats imported tokens from external files the same as local tokens', async () => { await run({ ...defaultOptions }); expect(await readFile(getFixturePath('/test/1.css.d.ts'), 'utf8')).toMatchInlineSnapshot(` - "declare const styles: - & Readonly> - & Readonly<{ "c": string }> - & Readonly<{ "a": string }> - ; - export default styles; - //# sourceMappingURL=./1.css.d.ts.map - " - `); + "declare const styles: + & Readonly + & Readonly<{ "a": string }> + ; + export default styles; + //# sourceMappingURL=./1.css.d.ts.map + " + `); }); }); diff --git a/packages/happy-css-modules/src/runner.ts b/packages/happy-css-modules/src/runner.ts index eb92eb0..b4a4126 100644 --- a/packages/happy-css-modules/src/runner.ts +++ b/packages/happy-css-modules/src/runner.ts @@ -133,6 +133,7 @@ export async function run(options: RunnerOptions): Promise { } // Locator#load cannot be called concurrently. Therefore, it takes a lock and waits. + // TODO: Concurrent calls to Locator#load are now allowed. Therefore, it is necessary to remove the lock. await lock.acquireAsync(); try { @@ -152,7 +153,7 @@ export async function run(options: RunnerOptions): Promise { const result = await locator.load(filePath); await emitGeneratedFiles({ filePath, - tokens: result.tokens, + tokenInfos: result.tokenInfos, emitDeclarationMap: options.declarationMap, dtsFormatOptions: { localsConvention: options.localsConvention,