Skip to content
Merged
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
44 changes: 43 additions & 1 deletion packages/create-cli/src/lib/setup/codegen.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,10 @@
import path from 'node:path';
import { toUnixPath } from '@code-pushup/utils';
import type { CategoryRef } from '@code-pushup/models';
import {
mergeCategoriesBySlug,
singleQuote,
toUnixPath,
} from '@code-pushup/utils';
import type {
ConfigFileFormat,
ImportDeclarationStructure,
Expand Down Expand Up @@ -43,11 +48,13 @@ export function generateConfigSource(
if (format === 'ts') {
builder.addLine('export default {');
addPlugins(builder, plugins);
addCategories(builder, plugins);
builder.addLine('} satisfies CoreConfig;');
} else {
builder.addLine("/** @type {import('@code-pushup/models').CoreConfig} */");
builder.addLine('export default {');
addPlugins(builder, plugins);
addCategories(builder, plugins);
builder.addLine('};');
}
return builder.toString();
Expand Down Expand Up @@ -172,6 +179,41 @@ function addPresetExport(
}
builder.addLine('return {', 1);
addPlugins(builder, plugins, 2);
addCategories(builder, plugins, 2);
builder.addLine('};', 1);
builder.addLine('}');
}

function addCategories(
builder: CodeBuilder,
plugins: PluginCodegenResult[],
depth = 1,
): void {
const categories = mergeCategoriesBySlug(
plugins.flatMap(p => p.categories ?? []),
);
if (categories.length === 0) {
return;
}
builder.addLine('categories: [', depth);
categories.forEach(({ slug, title, description, docsUrl, refs }) => {
builder.addLine('{', depth + 1);
builder.addLine(`slug: '${slug}',`, depth + 2);
builder.addLine(`title: ${singleQuote(title)},`, depth + 2);
if (description) {
builder.addLine(`description: ${singleQuote(description)},`, depth + 2);
}
if (docsUrl) {
builder.addLine(`docsUrl: ${singleQuote(docsUrl)},`, depth + 2);
}
builder.addLine('refs: [', depth + 2);
builder.addLines(refs.map(formatCategoryRef), depth + 3);
builder.addLine('],', depth + 2);
builder.addLine('},', depth + 1);
});
builder.addLine('],', depth);
}

function formatCategoryRef(ref: CategoryRef): string {
return `{ type: '${ref.type}', plugin: '${ref.plugin}', slug: '${ref.slug}', weight: ${ref.weight} },`;
}
205 changes: 205 additions & 0 deletions packages/create-cli/src/lib/setup/codegen.unit.test.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import type { CategoryConfig } from '@code-pushup/models';
import {
computeRelativePresetImport,
generateConfigSource,
Expand All @@ -16,6 +17,24 @@ const ESLINT_PLUGIN: PluginCodegenResult = {
pluginInit: "await eslintPlugin({ patterns: '.' })",
};

const ESLINT_CATEGORIES: CategoryConfig[] = [
{
slug: 'bug-prevention',
title: 'Bug prevention',
refs: [{ type: 'group', plugin: 'eslint', slug: 'problems', weight: 1 }],
},
{
slug: 'code-style',
title: 'Code style',
refs: [{ type: 'group', plugin: 'eslint', slug: 'suggestions', weight: 1 }],
},
];

const ESLINT_PLUGIN_WITH_CATEGORIES: PluginCodegenResult = {
...ESLINT_PLUGIN,
categories: ESLINT_CATEGORIES,
};

describe('generateConfigSource', () => {
describe('TypeScript format', () => {
it('should generate config with TODO placeholder when no plugins provided', () => {
Expand Down Expand Up @@ -201,6 +220,155 @@ describe('generateConfigSource', () => {
);
});
});

describe('categories', () => {
it('should include categories block when plugin provides categories', () => {
expect(generateConfigSource([ESLINT_PLUGIN_WITH_CATEGORIES], 'ts'))
.toMatchInlineSnapshot(`
"import eslintPlugin from '@code-pushup/eslint-plugin';
import type { CoreConfig } from '@code-pushup/models';

export default {
plugins: [
await eslintPlugin({ patterns: '.' }),
],
categories: [
{
slug: 'bug-prevention',
title: 'Bug prevention',
refs: [
{ type: 'group', plugin: 'eslint', slug: 'problems', weight: 1 },
],
},
{
slug: 'code-style',
title: 'Code style',
refs: [
{ type: 'group', plugin: 'eslint', slug: 'suggestions', weight: 1 },
],
},
],
} satisfies CoreConfig;
"
`);
});

it('should omit categories block when no categories provided', () => {
const source = generateConfigSource([ESLINT_PLUGIN], 'ts');
expect(source).not.toContain('categories');
});

it('should merge categories from multiple plugins', () => {
const coveragePlugin: PluginCodegenResult = {
imports: [
{
moduleSpecifier: '@code-pushup/coverage-plugin',
defaultImport: 'coveragePlugin',
},
],
pluginInit: 'await coveragePlugin()',
categories: [
{
slug: 'code-coverage',
title: 'Code coverage',
refs: [
{
type: 'group',
plugin: 'coverage',
slug: 'coverage',
weight: 1,
},
],
},
],
};
const source = generateConfigSource(
[ESLINT_PLUGIN_WITH_CATEGORIES, coveragePlugin],
'ts',
);
expect(source).toContain("slug: 'bug-prevention'");
expect(source).toContain("slug: 'code-style'");
expect(source).toContain("slug: 'code-coverage'");
});

it('should include categories in JS format config', () => {
const source = generateConfigSource(
[ESLINT_PLUGIN_WITH_CATEGORIES],
'js',
);
expect(source).toContain('categories: [');
expect(source).toContain("slug: 'bug-prevention'");
});

it.each([
["Project's docs", String.raw`title: 'Project\'s docs'`],
[String.raw`C:\Users\test`, String.raw`title: 'C:\\Users\\test'`],
['Line one\nLine two', String.raw`title: 'Line one\nLine two'`],
])('should escape %j in category title', (title, expected) => {
const plugin: PluginCodegenResult = {
...ESLINT_PLUGIN,
categories: [
{
slug: 'test',
title,
refs: [{ type: 'audit', plugin: 'p', slug: 's', weight: 1 }],
},
],
};
expect(generateConfigSource([plugin], 'ts')).toContain(expected);
});

it('should include description and docsUrl when provided', () => {
const plugin: PluginCodegenResult = {
...ESLINT_PLUGIN,
categories: [
{
slug: 'perf',
title: 'Performance',
description: 'Measures runtime performance.',
docsUrl: 'https://example.com/perf',
refs: [{ type: 'audit', plugin: 'perf', slug: 'lcp', weight: 1 }],
},
],
};
const source = generateConfigSource([plugin], 'ts');
expect(source).toContain("description: 'Measures runtime performance.'");
expect(source).toContain("docsUrl: 'https://example.com/perf'");
});

it('should merge categories with same slug from different plugins', () => {
const ref = (plugin: string, slug: string) => ({
type: 'group' as const,
plugin,
slug,
weight: 1,
});
const source = generateConfigSource(
[
{
...ESLINT_PLUGIN,
categories: [
{
slug: 'bugs',
title: 'Bugs',
refs: [ref('eslint', 'problems')],
},
],
},
{
...ESLINT_PLUGIN,
categories: [
{ slug: 'bugs', title: 'Bugs', refs: [ref('ts', 'errors')] },
],
},
],
'ts',
);
expect(source.match(/slug: 'bugs'/g)).toHaveLength(1);
expect(source).toContain("plugin: 'eslint'");
expect(source).toContain("plugin: 'ts'");
});
});
});

describe('generatePresetSource', () => {
Expand Down Expand Up @@ -243,6 +411,43 @@ describe('generatePresetSource', () => {
"
`);
});

it('should include categories in TS preset source', () => {
expect(generatePresetSource([ESLINT_PLUGIN_WITH_CATEGORIES], 'ts'))
.toMatchInlineSnapshot(`
"import eslintPlugin from '@code-pushup/eslint-plugin';
import type { CoreConfig } from '@code-pushup/models';

/**
* Creates a Code PushUp config for a project.
* @param project Project name
*/
export async function createConfig(project: string): Promise<CoreConfig> {
return {
plugins: [
await eslintPlugin({ patterns: '.' }),
],
categories: [
{
slug: 'bug-prevention',
title: 'Bug prevention',
refs: [
{ type: 'group', plugin: 'eslint', slug: 'problems', weight: 1 },
],
},
{
slug: 'code-style',
title: 'Code style',
refs: [
{ type: 'group', plugin: 'eslint', slug: 'suggestions', weight: 1 },
],
},
],
};
}
"
`);
});
});

describe('generateProjectSource', () => {
Expand Down
4 changes: 2 additions & 2 deletions packages/create-cli/src/lib/setup/types.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import type { PluginMeta } from '@code-pushup/models';
import type { CategoryConfig, PluginMeta } from '@code-pushup/models';
import type { MonorepoTool } from '@code-pushup/utils';

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

export type ScopedPluginResult = {
Expand Down
3 changes: 2 additions & 1 deletion packages/utils/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,7 @@ export {
pluralizeToken,
roundDecimals,
serializeCommandWithArgs,
singleQuote,
slugify,
transformLines,
truncateDescription,
Expand Down Expand Up @@ -93,7 +94,7 @@ export {
} from './lib/guards.js';
export { interpolate } from './lib/interpolate.js';
export { Logger, logger } from './lib/logger.js';
export { mergeConfigs } from './lib/merge-configs.js';
export { mergeCategoriesBySlug, mergeConfigs } from './lib/merge-configs.js';
export { loadNxProjectGraph } from './lib/nx.js';
export {
addIndex,
Expand Down
9 changes: 9 additions & 0 deletions packages/utils/src/lib/formatting.ts
Original file line number Diff line number Diff line change
Expand Up @@ -196,3 +196,12 @@ export function formatCoveragePercentage(stats: {
const percentage = (covered / total) * 100;
return `${percentage.toFixed(1)}%`;
}

/** Escapes a string and wraps it in single quotes for use in JS code. */
export function singleQuote(value: string): string {
const inner = JSON.stringify(value)
.slice(1, -1)
.replace(/\\"/g, '"')
.replace(/'/g, String.raw`\'`);
return `'${inner}'`;
}
19 changes: 19 additions & 0 deletions packages/utils/src/lib/formatting.unit.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import {
pluralizeToken,
roundDecimals,
serializeCommandWithArgs,
singleQuote,
slugify,
transformLines,
truncateMultilineText,
Expand Down Expand Up @@ -320,3 +321,21 @@ describe('formatCoveragePercentage', () => {
expect(formatCoveragePercentage({ covered: 0, total: 0 })).toBe('-');
});
});

describe('singleQuote', () => {
it.each([
['hello', "'hello'"],
["it's", String.raw`'it\'s'`],
[String.raw`back\slash`, String.raw`'back\\slash'`],
['line\nbreak', String.raw`'line\nbreak'`],
])(
'should escape %j for use in a single-quoted JS literal',
(input, expected) => {
expect(singleQuote(input)).toBe(expected);
},
);

it('should leave double quotes unescaped', () => {
expect(singleQuote('say "hi"')).toBe(`'say "hi"'`);
});
});
Loading
Loading