Skip to content

Commit b07d498

Browse files
authored
feat(create-cli): add categories codegen (#1267)
1 parent d13cc6e commit b07d498

File tree

8 files changed

+475
-6
lines changed

8 files changed

+475
-6
lines changed

packages/create-cli/src/lib/setup/codegen.ts

Lines changed: 43 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,10 @@
11
import path from 'node:path';
2-
import { toUnixPath } from '@code-pushup/utils';
2+
import type { CategoryRef } from '@code-pushup/models';
3+
import {
4+
mergeCategoriesBySlug,
5+
singleQuote,
6+
toUnixPath,
7+
} from '@code-pushup/utils';
38
import type {
49
ConfigFileFormat,
510
ImportDeclarationStructure,
@@ -43,11 +48,13 @@ export function generateConfigSource(
4348
if (format === 'ts') {
4449
builder.addLine('export default {');
4550
addPlugins(builder, plugins);
51+
addCategories(builder, plugins);
4652
builder.addLine('} satisfies CoreConfig;');
4753
} else {
4854
builder.addLine("/** @type {import('@code-pushup/models').CoreConfig} */");
4955
builder.addLine('export default {');
5056
addPlugins(builder, plugins);
57+
addCategories(builder, plugins);
5158
builder.addLine('};');
5259
}
5360
return builder.toString();
@@ -172,6 +179,41 @@ function addPresetExport(
172179
}
173180
builder.addLine('return {', 1);
174181
addPlugins(builder, plugins, 2);
182+
addCategories(builder, plugins, 2);
175183
builder.addLine('};', 1);
176184
builder.addLine('}');
177185
}
186+
187+
function addCategories(
188+
builder: CodeBuilder,
189+
plugins: PluginCodegenResult[],
190+
depth = 1,
191+
): void {
192+
const categories = mergeCategoriesBySlug(
193+
plugins.flatMap(p => p.categories ?? []),
194+
);
195+
if (categories.length === 0) {
196+
return;
197+
}
198+
builder.addLine('categories: [', depth);
199+
categories.forEach(({ slug, title, description, docsUrl, refs }) => {
200+
builder.addLine('{', depth + 1);
201+
builder.addLine(`slug: '${slug}',`, depth + 2);
202+
builder.addLine(`title: ${singleQuote(title)},`, depth + 2);
203+
if (description) {
204+
builder.addLine(`description: ${singleQuote(description)},`, depth + 2);
205+
}
206+
if (docsUrl) {
207+
builder.addLine(`docsUrl: ${singleQuote(docsUrl)},`, depth + 2);
208+
}
209+
builder.addLine('refs: [', depth + 2);
210+
builder.addLines(refs.map(formatCategoryRef), depth + 3);
211+
builder.addLine('],', depth + 2);
212+
builder.addLine('},', depth + 1);
213+
});
214+
builder.addLine('],', depth);
215+
}
216+
217+
function formatCategoryRef(ref: CategoryRef): string {
218+
return `{ type: '${ref.type}', plugin: '${ref.plugin}', slug: '${ref.slug}', weight: ${ref.weight} },`;
219+
}

packages/create-cli/src/lib/setup/codegen.unit.test.ts

Lines changed: 205 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import type { CategoryConfig } from '@code-pushup/models';
12
import {
23
computeRelativePresetImport,
34
generateConfigSource,
@@ -16,6 +17,24 @@ const ESLINT_PLUGIN: PluginCodegenResult = {
1617
pluginInit: "await eslintPlugin({ patterns: '.' })",
1718
};
1819

20+
const ESLINT_CATEGORIES: CategoryConfig[] = [
21+
{
22+
slug: 'bug-prevention',
23+
title: 'Bug prevention',
24+
refs: [{ type: 'group', plugin: 'eslint', slug: 'problems', weight: 1 }],
25+
},
26+
{
27+
slug: 'code-style',
28+
title: 'Code style',
29+
refs: [{ type: 'group', plugin: 'eslint', slug: 'suggestions', weight: 1 }],
30+
},
31+
];
32+
33+
const ESLINT_PLUGIN_WITH_CATEGORIES: PluginCodegenResult = {
34+
...ESLINT_PLUGIN,
35+
categories: ESLINT_CATEGORIES,
36+
};
37+
1938
describe('generateConfigSource', () => {
2039
describe('TypeScript format', () => {
2140
it('should generate config with TODO placeholder when no plugins provided', () => {
@@ -201,6 +220,155 @@ describe('generateConfigSource', () => {
201220
);
202221
});
203222
});
223+
224+
describe('categories', () => {
225+
it('should include categories block when plugin provides categories', () => {
226+
expect(generateConfigSource([ESLINT_PLUGIN_WITH_CATEGORIES], 'ts'))
227+
.toMatchInlineSnapshot(`
228+
"import eslintPlugin from '@code-pushup/eslint-plugin';
229+
import type { CoreConfig } from '@code-pushup/models';
230+
231+
export default {
232+
plugins: [
233+
await eslintPlugin({ patterns: '.' }),
234+
],
235+
categories: [
236+
{
237+
slug: 'bug-prevention',
238+
title: 'Bug prevention',
239+
refs: [
240+
{ type: 'group', plugin: 'eslint', slug: 'problems', weight: 1 },
241+
],
242+
},
243+
{
244+
slug: 'code-style',
245+
title: 'Code style',
246+
refs: [
247+
{ type: 'group', plugin: 'eslint', slug: 'suggestions', weight: 1 },
248+
],
249+
},
250+
],
251+
} satisfies CoreConfig;
252+
"
253+
`);
254+
});
255+
256+
it('should omit categories block when no categories provided', () => {
257+
const source = generateConfigSource([ESLINT_PLUGIN], 'ts');
258+
expect(source).not.toContain('categories');
259+
});
260+
261+
it('should merge categories from multiple plugins', () => {
262+
const coveragePlugin: PluginCodegenResult = {
263+
imports: [
264+
{
265+
moduleSpecifier: '@code-pushup/coverage-plugin',
266+
defaultImport: 'coveragePlugin',
267+
},
268+
],
269+
pluginInit: 'await coveragePlugin()',
270+
categories: [
271+
{
272+
slug: 'code-coverage',
273+
title: 'Code coverage',
274+
refs: [
275+
{
276+
type: 'group',
277+
plugin: 'coverage',
278+
slug: 'coverage',
279+
weight: 1,
280+
},
281+
],
282+
},
283+
],
284+
};
285+
const source = generateConfigSource(
286+
[ESLINT_PLUGIN_WITH_CATEGORIES, coveragePlugin],
287+
'ts',
288+
);
289+
expect(source).toContain("slug: 'bug-prevention'");
290+
expect(source).toContain("slug: 'code-style'");
291+
expect(source).toContain("slug: 'code-coverage'");
292+
});
293+
294+
it('should include categories in JS format config', () => {
295+
const source = generateConfigSource(
296+
[ESLINT_PLUGIN_WITH_CATEGORIES],
297+
'js',
298+
);
299+
expect(source).toContain('categories: [');
300+
expect(source).toContain("slug: 'bug-prevention'");
301+
});
302+
303+
it.each([
304+
["Project's docs", String.raw`title: 'Project\'s docs'`],
305+
[String.raw`C:\Users\test`, String.raw`title: 'C:\\Users\\test'`],
306+
['Line one\nLine two', String.raw`title: 'Line one\nLine two'`],
307+
])('should escape %j in category title', (title, expected) => {
308+
const plugin: PluginCodegenResult = {
309+
...ESLINT_PLUGIN,
310+
categories: [
311+
{
312+
slug: 'test',
313+
title,
314+
refs: [{ type: 'audit', plugin: 'p', slug: 's', weight: 1 }],
315+
},
316+
],
317+
};
318+
expect(generateConfigSource([plugin], 'ts')).toContain(expected);
319+
});
320+
321+
it('should include description and docsUrl when provided', () => {
322+
const plugin: PluginCodegenResult = {
323+
...ESLINT_PLUGIN,
324+
categories: [
325+
{
326+
slug: 'perf',
327+
title: 'Performance',
328+
description: 'Measures runtime performance.',
329+
docsUrl: 'https://example.com/perf',
330+
refs: [{ type: 'audit', plugin: 'perf', slug: 'lcp', weight: 1 }],
331+
},
332+
],
333+
};
334+
const source = generateConfigSource([plugin], 'ts');
335+
expect(source).toContain("description: 'Measures runtime performance.'");
336+
expect(source).toContain("docsUrl: 'https://example.com/perf'");
337+
});
338+
339+
it('should merge categories with same slug from different plugins', () => {
340+
const ref = (plugin: string, slug: string) => ({
341+
type: 'group' as const,
342+
plugin,
343+
slug,
344+
weight: 1,
345+
});
346+
const source = generateConfigSource(
347+
[
348+
{
349+
...ESLINT_PLUGIN,
350+
categories: [
351+
{
352+
slug: 'bugs',
353+
title: 'Bugs',
354+
refs: [ref('eslint', 'problems')],
355+
},
356+
],
357+
},
358+
{
359+
...ESLINT_PLUGIN,
360+
categories: [
361+
{ slug: 'bugs', title: 'Bugs', refs: [ref('ts', 'errors')] },
362+
],
363+
},
364+
],
365+
'ts',
366+
);
367+
expect(source.match(/slug: 'bugs'/g)).toHaveLength(1);
368+
expect(source).toContain("plugin: 'eslint'");
369+
expect(source).toContain("plugin: 'ts'");
370+
});
371+
});
204372
});
205373

206374
describe('generatePresetSource', () => {
@@ -243,6 +411,43 @@ describe('generatePresetSource', () => {
243411
"
244412
`);
245413
});
414+
415+
it('should include categories in TS preset source', () => {
416+
expect(generatePresetSource([ESLINT_PLUGIN_WITH_CATEGORIES], 'ts'))
417+
.toMatchInlineSnapshot(`
418+
"import eslintPlugin from '@code-pushup/eslint-plugin';
419+
import type { CoreConfig } from '@code-pushup/models';
420+
421+
/**
422+
* Creates a Code PushUp config for a project.
423+
* @param project Project name
424+
*/
425+
export async function createConfig(project: string): Promise<CoreConfig> {
426+
return {
427+
plugins: [
428+
await eslintPlugin({ patterns: '.' }),
429+
],
430+
categories: [
431+
{
432+
slug: 'bug-prevention',
433+
title: 'Bug prevention',
434+
refs: [
435+
{ type: 'group', plugin: 'eslint', slug: 'problems', weight: 1 },
436+
],
437+
},
438+
{
439+
slug: 'code-style',
440+
title: 'Code style',
441+
refs: [
442+
{ type: 'group', plugin: 'eslint', slug: 'suggestions', weight: 1 },
443+
],
444+
},
445+
],
446+
};
447+
}
448+
"
449+
`);
450+
});
246451
});
247452

248453
describe('generateProjectSource', () => {

packages/create-cli/src/lib/setup/types.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import type { PluginMeta } from '@code-pushup/models';
1+
import type { CategoryConfig, PluginMeta } from '@code-pushup/models';
22
import type { MonorepoTool } from '@code-pushup/utils';
33

44
export const CI_PROVIDERS = ['github', 'gitlab', 'none'] as const;
@@ -65,7 +65,7 @@ export type ImportDeclarationStructure = {
6565
export type PluginCodegenResult = {
6666
imports: ImportDeclarationStructure[];
6767
pluginInit: string;
68-
// TODO: add categories support (categoryRefs for generated categories array)
68+
categories?: CategoryConfig[];
6969
};
7070

7171
export type ScopedPluginResult = {

packages/utils/src/index.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,7 @@ export {
6060
pluralizeToken,
6161
roundDecimals,
6262
serializeCommandWithArgs,
63+
singleQuote,
6364
slugify,
6465
transformLines,
6566
truncateDescription,
@@ -93,7 +94,7 @@ export {
9394
} from './lib/guards.js';
9495
export { interpolate } from './lib/interpolate.js';
9596
export { Logger, logger } from './lib/logger.js';
96-
export { mergeConfigs } from './lib/merge-configs.js';
97+
export { mergeCategoriesBySlug, mergeConfigs } from './lib/merge-configs.js';
9798
export { loadNxProjectGraph } from './lib/nx.js';
9899
export {
99100
addIndex,

packages/utils/src/lib/formatting.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -196,3 +196,12 @@ export function formatCoveragePercentage(stats: {
196196
const percentage = (covered / total) * 100;
197197
return `${percentage.toFixed(1)}%`;
198198
}
199+
200+
/** Escapes a string and wraps it in single quotes for use in JS code. */
201+
export function singleQuote(value: string): string {
202+
const inner = JSON.stringify(value)
203+
.slice(1, -1)
204+
.replace(/\\"/g, '"')
205+
.replace(/'/g, String.raw`\'`);
206+
return `'${inner}'`;
207+
}

packages/utils/src/lib/formatting.unit.test.ts

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import {
1010
pluralizeToken,
1111
roundDecimals,
1212
serializeCommandWithArgs,
13+
singleQuote,
1314
slugify,
1415
transformLines,
1516
truncateMultilineText,
@@ -320,3 +321,21 @@ describe('formatCoveragePercentage', () => {
320321
expect(formatCoveragePercentage({ covered: 0, total: 0 })).toBe('-');
321322
});
322323
});
324+
325+
describe('singleQuote', () => {
326+
it.each([
327+
['hello', "'hello'"],
328+
["it's", String.raw`'it\'s'`],
329+
[String.raw`back\slash`, String.raw`'back\\slash'`],
330+
['line\nbreak', String.raw`'line\nbreak'`],
331+
])(
332+
'should escape %j for use in a single-quoted JS literal',
333+
(input, expected) => {
334+
expect(singleQuote(input)).toBe(expected);
335+
},
336+
);
337+
338+
it('should leave double quotes unescaped', () => {
339+
expect(singleQuote('say "hi"')).toBe(`'say "hi"'`);
340+
});
341+
});

0 commit comments

Comments
 (0)