Skip to content

Commit

Permalink
support tokenInfo for emitter
Browse files Browse the repository at this point in the history
  • Loading branch information
mizdra committed Jun 15, 2024
1 parent 2d63a9b commit 2ec229c
Show file tree
Hide file tree
Showing 5 changed files with 153 additions and 82 deletions.
62 changes: 47 additions & 15 deletions packages/happy-css-modules/src/emitter/dts.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -43,14 +44,13 @@ describe('generateDtsContentWithSourceMap', () => {
filePath,
dtsFilePath,
sourceMapFilePath,
result.tokens,
result.tokenInfos,
dtsFormatOptions,
isExternalFile,
);
expect(dtsContent).toMatchInlineSnapshot(`
"declare const styles:
& Readonly<Pick<(typeof import("./3.css"))["default"], "d">>
& Readonly<Pick<(typeof import("./2.css"))["default"], "c">>
& Readonly<typeof import("./2.css")["default"]>
& Readonly<{ "a": string }>
& Readonly<{ "b": string }>
& Readonly<{ "b": string }>
Expand All @@ -59,23 +59,23 @@ 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,
"name": "a",
"source": "1.css",
}
`);
expect(smc.originalPositionFor({ line: 5, column: 15 })).toMatchInlineSnapshot(`
expect(smc.originalPositionFor({ line: 4, column: 15 })).toMatchInlineSnapshot(`
{
"column": 0,
"line": 3,
"name": "b",
"source": "1.css",
}
`);
expect(smc.originalPositionFor({ line: 6, column: 15 })).toMatchInlineSnapshot(`
expect(smc.originalPositionFor({ line: 5, column: 15 })).toMatchInlineSnapshot(`
{
"column": 0,
"line": 4,
Expand All @@ -100,7 +100,7 @@ describe('generateDtsContentWithSourceMap', () => {
filePath,
dtsFilePath,
sourceMapFilePath,
result.tokens,
result.tokenInfos,
{
...dtsFormatOptions,
localsConvention: undefined,
Expand All @@ -122,7 +122,7 @@ describe('generateDtsContentWithSourceMap', () => {
filePath,
dtsFilePath,
sourceMapFilePath,
result.tokens,
result.tokenInfos,
{
...dtsFormatOptions,
localsConvention: 'camelCaseOnly',
Expand All @@ -144,7 +144,7 @@ describe('generateDtsContentWithSourceMap', () => {
filePath,
dtsFilePath,
sourceMapFilePath,
result.tokens,
result.tokenInfos,
{
...dtsFormatOptions,
localsConvention: 'camelCase',
Expand All @@ -168,7 +168,7 @@ describe('generateDtsContentWithSourceMap', () => {
filePath,
dtsFilePath,
sourceMapFilePath,
result.tokens,
result.tokenInfos,
{
...dtsFormatOptions,
localsConvention: 'dashesOnly',
Expand All @@ -190,7 +190,7 @@ describe('generateDtsContentWithSourceMap', () => {
filePath,
dtsFilePath,
sourceMapFilePath,
result.tokens,
result.tokenInfos,
{
...dtsFormatOptions,
localsConvention: 'dashes',
Expand Down Expand Up @@ -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,
);
Expand All @@ -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';
Expand All @@ -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<Pick<(typeof import("./2.css"))["default"], "b">>
& Readonly<typeof import("./2.css")["default"]>
& 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<Pick<(typeof import("./2.scss"))["default"], "b">>
& Readonly<{ "c": string }>
& Readonly<{ "a": string }>
;
Expand Down
153 changes: 96 additions & 57 deletions packages/happy-css-modules/src/emitter/dts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand All @@ -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<Pick<(typeof import(',
`"${getRelativePath(filePath, originalLocation.filePath)}"`,
'))["default"], ',
`"${formattedTokenName}"`,
'>>',
]),
);
}
return result;
}

function generateTokenDeclarationForImportedAllTokensFromModule(
filePath: string,
importedAllTokensFromModule: ImportedAllTokensFromModule,
): typeof SourceNode {
return new SourceNode(null, null, null, [
'& Readonly<typeof import(',
`"${getRelativePath(filePath, importedAllTokensFromModule.filePath)}"`,
')["default"]>',
]);
}

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<Pick<(typeof import(',
`"${getRelativePath(filePath, originalLocation.filePath)}"`,
'))["default"], ',
`"${token.name}"`,
'>>',
]),
...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;
Expand All @@ -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,
);
Expand Down
6 changes: 3 additions & 3 deletions packages/happy-css-modules/src/emitter/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,20 +56,20 @@ 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
expect(mtimeForSourceMap1).toEqual(mtimeForSourceMap2); // skipped

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
Expand Down
Loading

0 comments on commit 2ec229c

Please sign in to comment.