Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Improve the format of Dependent Type Definition File #250

Closed
wants to merge 5 commits into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions jest.setup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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') &&
Expand Down
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 @@ -37,7 +37,7 @@
});
});
test('generates .d.ts and .d.ts.map', async () => {
await emitGeneratedFiles({ ...defaultArgs });

Check failure on line 40 in packages/happy-css-modules/src/emitter/index.test.ts

View workflow job for this annotation

GitHub Actions / lint

Argument of type '{ filePath: string; tokens: Token[]; emitDeclarationMap: boolean; dtsFormatOptions: undefined; cwd: string; isExternalFile: () => boolean; }' is not assignable to parameter of type 'EmitterOptions'.
expect(await exists(getFixturePath('/test/1.css.d.ts'))).toBeTruthy();
// A link to the source map is embedded.
expect(await readFile(getFixturePath('/test/1.css.d.ts'), 'utf8')).toEqual(
Expand All @@ -46,7 +46,7 @@
expect(await exists(getFixturePath('/test/1.css.d.ts.map'))).toBeTruthy();
});
test('generates only .d.ts and .d.ts.map if emitDeclarationMap is false', async () => {
await emitGeneratedFiles({ ...defaultArgs, emitDeclarationMap: false });

Check failure on line 49 in packages/happy-css-modules/src/emitter/index.test.ts

View workflow job for this annotation

GitHub Actions / lint

Argument of type '{ emitDeclarationMap: false; filePath: string; tokens: Token[]; dtsFormatOptions: undefined; cwd: string; isExternalFile: () => boolean; }' is not assignable to parameter of type 'EmitterOptions'.
expect(await exists(getFixturePath('/test/1.css.d.ts'))).toBeTruthy();
// A link to the source map is not embedded.
expect(await readFile(getFixturePath('/test/1.css.d.ts'), 'utf8')).toEqual(
Expand All @@ -56,20 +56,20 @@
});
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
Loading