Skip to content

Commit a81c8c3

Browse files
committed
feat(plugin-typescript): add plugin logic
1 parent afd50e7 commit a81c8c3

File tree

6 files changed

+323
-0
lines changed

6 files changed

+323
-0
lines changed
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
import { z } from 'zod';
2+
import { AUDITS, DEFAULT_TS_CONFIG } from './constants.js';
3+
import type { AuditSlug } from './types.js';
4+
5+
const auditSlugs = AUDITS.map(({ slug }) => slug) as [
6+
AuditSlug,
7+
...AuditSlug[],
8+
];
9+
export const typescriptPluginConfigSchema = z.object({
10+
tsconfig: z
11+
.string({
12+
description: 'Path to the TsConfig',
13+
})
14+
.default(DEFAULT_TS_CONFIG),
15+
onlyAudits: z
16+
.array(z.enum(auditSlugs), {
17+
description: 'Array with specific TsCodes to measure',
18+
})
19+
.optional(),
20+
});
21+
22+
export type TypescriptPluginOptions = z.infer<
23+
typeof typescriptPluginConfigSchema
24+
>;
Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
import { describe, expect, it } from 'vitest';
2+
import {
3+
type TypescriptPluginOptions,
4+
typescriptPluginConfigSchema,
5+
} from './schema.js';
6+
7+
describe('typescriptPluginConfigSchema', () => {
8+
const tsConfigPath = 'tsconfig.json';
9+
10+
it('accepts a empty configuration', () => {
11+
expect(() => typescriptPluginConfigSchema.parse({})).not.toThrow();
12+
});
13+
14+
it('accepts a configuration with tsConfigPath set', () => {
15+
expect(() =>
16+
typescriptPluginConfigSchema.parse({
17+
tsConfigPath,
18+
} satisfies TypescriptPluginOptions),
19+
).not.toThrow();
20+
});
21+
22+
it('accepts a configuration with tsConfigPath and empty onlyAudits', () => {
23+
expect(() =>
24+
typescriptPluginConfigSchema.parse({
25+
tsConfigPath,
26+
onlyAudits: [],
27+
} satisfies TypescriptPluginOptions),
28+
).not.toThrow();
29+
});
30+
31+
it('accepts a configuration with tsConfigPath and full onlyAudits', () => {
32+
expect(() =>
33+
typescriptPluginConfigSchema.parse({
34+
tsConfigPath,
35+
onlyAudits: [
36+
'syntax-errors',
37+
'semantic-errors',
38+
'configuration-errors',
39+
],
40+
} satisfies TypescriptPluginOptions),
41+
).not.toThrow();
42+
});
43+
44+
it('throws for invalid onlyAudits', () => {
45+
expect(() =>
46+
typescriptPluginConfigSchema.parse({
47+
onlyAudits: 123,
48+
}),
49+
).toThrow('invalid_type');
50+
});
51+
52+
it('throws for invalid onlyAudits items', () => {
53+
expect(() =>
54+
typescriptPluginConfigSchema.parse({
55+
tsConfigPath,
56+
onlyAudits: [123, true],
57+
}),
58+
).toThrow('invalid_type');
59+
});
60+
61+
it('throws for unknown audit slug', () => {
62+
expect(
63+
() =>
64+
typescriptPluginConfigSchema.parse({
65+
tsConfigPath,
66+
onlyAudits: ['unknown-audit'],
67+
}),
68+
// Message too large because enums validation
69+
// eslint-disable-next-line vitest/require-to-throw-message
70+
).toThrow();
71+
});
72+
});
Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
import { createRequire } from 'node:module';
2+
import type { PluginConfig } from '@code-pushup/models';
3+
import { DEFAULT_TS_CONFIG, TYPESCRIPT_PLUGIN_SLUG } from './constants.js';
4+
import { createRunnerFunction } from './runner/runner.js';
5+
import type { DiagnosticsOptions } from './runner/ts-runner.js';
6+
import { typescriptPluginConfigSchema } from './schema.js';
7+
import type { AuditSlug } from './types.js';
8+
import { getAudits, getGroups, logSkippedAudits } from './utils.js';
9+
10+
const packageJson = createRequire(import.meta.url)(
11+
'../../package.json',
12+
) as typeof import('../../package.json');
13+
14+
export type FilterOptions = { onlyAudits?: AuditSlug[] | undefined };
15+
export type TypescriptPluginOptions = Partial<DiagnosticsOptions> &
16+
FilterOptions;
17+
18+
export async function typescriptPlugin(
19+
options?: TypescriptPluginOptions,
20+
): Promise<PluginConfig> {
21+
const { tsconfig = DEFAULT_TS_CONFIG, onlyAudits } = parseOptions(
22+
options ?? {},
23+
);
24+
25+
const filteredAudits = getAudits({ onlyAudits });
26+
const filteredGroups = getGroups({ onlyAudits });
27+
28+
logSkippedAudits(filteredAudits);
29+
30+
return {
31+
slug: TYPESCRIPT_PLUGIN_SLUG,
32+
packageName: packageJson.name,
33+
version: packageJson.version,
34+
title: 'Typescript',
35+
description: 'Official Code PushUp Typescript plugin.',
36+
docsUrl: 'https://www.npmjs.com/package/@code-pushup/typescript-plugin/',
37+
icon: 'typescript',
38+
audits: filteredAudits,
39+
groups: filteredGroups,
40+
runner: createRunnerFunction({
41+
tsconfig,
42+
expectedAudits: filteredAudits,
43+
}),
44+
};
45+
}
46+
47+
function parseOptions(
48+
tsPluginOptions: TypescriptPluginOptions,
49+
): TypescriptPluginOptions {
50+
try {
51+
return typescriptPluginConfigSchema.parse(tsPluginOptions);
52+
} catch (error) {
53+
throw new Error(
54+
`Error parsing TypeScript Plugin options: ${(error as Error).message}`,
55+
);
56+
}
57+
}
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
import { expect } from 'vitest';
2+
import { pluginConfigSchema } from '@code-pushup/models';
3+
import { AUDITS, GROUPS } from './constants.js';
4+
import { typescriptPlugin } from './typescript-plugin.js';
5+
6+
describe('typescriptPlugin-config-object', () => {
7+
it('should create valid plugin config without options', async () => {
8+
const pluginConfig = await typescriptPlugin();
9+
10+
expect(() => pluginConfigSchema.parse(pluginConfig)).not.toThrow();
11+
12+
const { audits, groups } = pluginConfig;
13+
expect(audits).toHaveLength(AUDITS.length);
14+
expect(groups).toBeDefined();
15+
expect(groups!).toHaveLength(GROUPS.length);
16+
});
17+
18+
it('should create valid plugin config', async () => {
19+
const pluginConfig = await typescriptPlugin({
20+
tsConfigPath: 'mocked-away/tsconfig.json',
21+
onlyAudits: ['syntax-errors', 'semantic-errors', 'configuration-errors'],
22+
});
23+
24+
expect(() => pluginConfigSchema.parse(pluginConfig)).not.toThrow();
25+
26+
const { audits, groups } = pluginConfig;
27+
expect(audits).toHaveLength(3);
28+
expect(groups).toBeDefined();
29+
expect(groups!).toHaveLength(2);
30+
});
31+
});

packages/utils/src/lib/string.ts

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
import type { CamelCaseToKebabCase } from './types';
2+
3+
/**
4+
* Converts a kebab-case string to camelCase.
5+
* @param string - The kebab-case string to convert.
6+
* @returns The camelCase string.
7+
*/
8+
export function kebabCaseToCamelCase(string: string) {
9+
return string
10+
.split('-')
11+
.map((segment, index) =>
12+
index === 0
13+
? segment
14+
: segment.charAt(0).toUpperCase() + segment.slice(1),
15+
)
16+
.join('');
17+
}
18+
19+
/**
20+
* Converts a camelCase string to kebab-case.
21+
* @param string - The camelCase string to convert.
22+
* @returns The kebab-case string.
23+
*/
24+
export function camelCaseToKebabCase<T extends string>(
25+
string: T,
26+
): CamelCaseToKebabCase<T> {
27+
return string
28+
.replace(/([A-Z])([A-Z][a-z])/g, '$1-$2') // Split between uppercase followed by uppercase+lowercase
29+
.replace(/([a-z])([A-Z])/g, '$1-$2') // Split between lowercase followed by uppercase
30+
.replace(/([A-Z]+)([A-Z][a-z])/g, '$1-$2') // Additional split for consecutive uppercase
31+
.replace(/[\s_]+/g, '-') // Replace spaces and underscores with hyphens
32+
.toLowerCase() as CamelCaseToKebabCase<T>;
33+
}
34+
35+
/**
36+
* Formats a slug to a readable title.
37+
* @param slug - The slug to format.
38+
* @returns The formatted title.
39+
*/
40+
export function kebabCaseToSentence(slug: string = '') {
41+
return slug
42+
.replace(/-/g, ' ')
43+
.replace(/\b\w/g, letter => letter.toUpperCase());
44+
}
45+
46+
/**
47+
* Formats a slug to a readable title.
48+
* @param slug - The slug to format.
49+
* @returns The formatted title.
50+
*/
51+
export function camelCaseToSentence(slug: string = '') {
52+
return slug
53+
.replace(/([A-Z])([A-Z][a-z])/g, '$1-$2') // Split between uppercase followed by uppercase+lowercase
54+
.replace(/([a-z])([A-Z])/g, '$1-$2') // Split between lowercase followed by uppercase
55+
.replace(/([A-Z]+)([A-Z][a-z])/g, '$1-$2') // Additional split for consecutive uppercase
56+
.replace(/[\s_]+/g, ' ') // Replace spaces and underscores with hyphens
57+
.replace(/\b\w/g, letter => letter.toUpperCase());
58+
}
Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
import {
2+
camelCaseToKebabCase,
3+
kebabCaseToCamelCase,
4+
kebabCaseToSentence,
5+
} from './string.js';
6+
7+
describe('kebabCaseToCamelCase', () => {
8+
it('should convert simple kebab-case to camelCase', () => {
9+
expect(kebabCaseToCamelCase('hello-world')).toBe('helloWorld');
10+
});
11+
12+
it('should handle multiple hyphens', () => {
13+
expect(kebabCaseToCamelCase('this-is-a-long-string')).toBe(
14+
'thisIsALongString',
15+
);
16+
});
17+
18+
it('should preserve numbers', () => {
19+
expect(kebabCaseToCamelCase('user-123-test')).toBe('user123Test');
20+
});
21+
22+
it('should handle single word', () => {
23+
expect(kebabCaseToCamelCase('hello')).toBe('hello');
24+
});
25+
26+
it('should handle empty string', () => {
27+
expect(kebabCaseToCamelCase('')).toBe('');
28+
});
29+
});
30+
31+
describe('camelCaseToKebabCase', () => {
32+
it('should convert simple camelCase to kebab-case', () => {
33+
expect(camelCaseToKebabCase('helloWorld')).toBe('hello-world');
34+
});
35+
36+
it('should handle multiple capital letters', () => {
37+
expect(camelCaseToKebabCase('thisIsALongString')).toBe(
38+
'this-is-a-long-string',
39+
);
40+
});
41+
42+
it('should handle consecutive capital letters', () => {
43+
expect(camelCaseToKebabCase('myXMLParser')).toBe('my-xml-parser');
44+
});
45+
46+
it('should handle spaces and underscores', () => {
47+
expect(camelCaseToKebabCase('hello_world test')).toBe('hello-world-test');
48+
});
49+
50+
it('should handle single word', () => {
51+
expect(camelCaseToKebabCase('hello')).toBe('hello');
52+
});
53+
54+
it('should handle empty string', () => {
55+
expect(camelCaseToKebabCase('')).toBe('');
56+
});
57+
});
58+
59+
describe('kebabCaseToSentence', () => {
60+
it('should convert simple slug to title case', () => {
61+
expect(kebabCaseToSentence('hello-world')).toBe('Hello World');
62+
});
63+
64+
it('should handle multiple hyphens', () => {
65+
expect(kebabCaseToSentence('this-is-a-title')).toBe('This Is A Title');
66+
});
67+
68+
it('should handle empty string', () => {
69+
expect(kebabCaseToSentence()).toBe('');
70+
});
71+
72+
it('should handle single word', () => {
73+
expect(kebabCaseToSentence('hello')).toBe('Hello');
74+
});
75+
76+
it('should handle numbers in slug', () => {
77+
expect(kebabCaseToSentence('chapter-1-introduction')).toBe(
78+
'Chapter 1 Introduction',
79+
);
80+
});
81+
});

0 commit comments

Comments
 (0)