Skip to content

Commit c87fde0

Browse files
committed
feat: merge categories and escape titles
1 parent 328d961 commit c87fde0

5 files changed

Lines changed: 288 additions & 7 deletions

File tree

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

Lines changed: 23 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import path from 'node:path';
22
import type { CategoryRef } from '@code-pushup/models';
3-
import { toUnixPath } from '@code-pushup/utils';
3+
import { mergeCategoriesBySlug, toUnixPath } from '@code-pushup/utils';
44
import type {
55
ConfigFileFormat,
66
ImportDeclarationStructure,
@@ -185,15 +185,26 @@ function addCategories(
185185
plugins: PluginCodegenResult[],
186186
depth = 1,
187187
): void {
188-
const categories = plugins.flatMap(p => p.categories ?? []);
188+
const categories = mergeCategoriesBySlug(
189+
plugins.flatMap(p => p.categories ?? []),
190+
);
189191
if (categories.length === 0) {
190192
return;
191193
}
192194
builder.addLine('categories: [', depth);
193-
categories.forEach(({ slug, title, refs }) => {
195+
categories.forEach(({ slug, title, description, docsUrl, refs }) => {
194196
builder.addLine('{', depth + 1);
195197
builder.addLine(`slug: '${slug}',`, depth + 2);
196-
builder.addLine(`title: '${title}',`, depth + 2);
198+
builder.addLine(`title: ${toJsStringLiteral(title)},`, depth + 2);
199+
if (description) {
200+
builder.addLine(
201+
`description: ${toJsStringLiteral(description)},`,
202+
depth + 2,
203+
);
204+
}
205+
if (docsUrl) {
206+
builder.addLine(`docsUrl: ${toJsStringLiteral(docsUrl)},`, depth + 2);
207+
}
197208
builder.addLine('refs: [', depth + 2);
198209
builder.addLines(refs.map(formatCategoryRef), depth + 3);
199210
builder.addLine('],', depth + 2);
@@ -205,3 +216,11 @@ function addCategories(
205216
function formatCategoryRef(ref: CategoryRef): string {
206217
return `{ type: '${ref.type}', plugin: '${ref.plugin}', slug: '${ref.slug}', weight: ${ref.weight} },`;
207218
}
219+
220+
/** Wraps a value in single-quoted JS string literal with special characters escaped. */
221+
function toJsStringLiteral(value: string): string {
222+
const inner = JSON.stringify(value)
223+
.slice(1, -1)
224+
.replace(/'/g, String.raw`\'`);
225+
return `'${inner}'`;
226+
}

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

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -299,6 +299,75 @@ describe('generateConfigSource', () => {
299299
expect(source).toContain('categories: [');
300300
expect(source).toContain("slug: 'bug-prevention'");
301301
});
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+
});
302371
});
303372
});
304373

packages/utils/src/index.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -93,7 +93,7 @@ export {
9393
} from './lib/guards.js';
9494
export { interpolate } from './lib/interpolate.js';
9595
export { Logger, logger } from './lib/logger.js';
96-
export { mergeConfigs } from './lib/merge-configs.js';
96+
export { mergeCategoriesBySlug, mergeConfigs } from './lib/merge-configs.js';
9797
export { loadNxProjectGraph } from './lib/nx.js';
9898
export {
9999
addIndex,

packages/utils/src/lib/merge-configs.ts

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,35 @@ function mergeCategories(
6565
return { categories: [...mergedMap.values()] };
6666
}
6767

68+
/** Deduplicates categories that share the same slug. */
69+
export function mergeCategoriesBySlug(
70+
categories: CategoryConfig[],
71+
): CategoryConfig[] {
72+
const map = categories.reduce((acc, category) => {
73+
const existing = acc.get(category.slug);
74+
acc.set(
75+
category.slug,
76+
existing
77+
? {
78+
slug: existing.slug,
79+
title: existing.title,
80+
description: mergeDescriptions(
81+
existing.description,
82+
category.description,
83+
),
84+
docsUrl: existing.docsUrl ?? category.docsUrl,
85+
refs: mergeByUniqueCategoryRefCombination(
86+
existing.refs,
87+
category.refs,
88+
),
89+
}
90+
: category,
91+
);
92+
return acc;
93+
}, new Map<string, CategoryConfig>());
94+
return [...map.values()];
95+
}
96+
6897
function mergePlugins(
6998
a: PluginConfig[] | undefined,
7099
b: PluginConfig[] | undefined,
@@ -150,3 +179,24 @@ function mergeUpload(
150179
return { upload: b };
151180
}
152181
}
182+
183+
function toSentence(text: string): string {
184+
const trimmed = text.trimEnd();
185+
if (trimmed.endsWith('.') || trimmed.endsWith('!') || trimmed.endsWith('?')) {
186+
return trimmed;
187+
}
188+
return `${trimmed}.`;
189+
}
190+
191+
function mergeDescriptions(
192+
a: string | undefined,
193+
b: string | undefined,
194+
): string | undefined {
195+
if (!a) {
196+
return b;
197+
}
198+
if (!b || a === b) {
199+
return a;
200+
}
201+
return `${toSentence(a)} ${toSentence(b)}`;
202+
}

packages/utils/src/lib/merge-configs.unit.test.ts

Lines changed: 145 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,9 @@
1-
import type { CoreConfig, PluginConfig } from '@code-pushup/models';
2-
import { mergeConfigs } from './merge-configs.js';
1+
import type {
2+
CategoryConfig,
3+
CoreConfig,
4+
PluginConfig,
5+
} from '@code-pushup/models';
6+
import { mergeCategoriesBySlug, mergeConfigs } from './merge-configs.js';
37

48
const MOCK_CONFIG_PERSIST = {
59
persist: {
@@ -328,3 +332,142 @@ describe('mergeObjects', () => {
328332
});
329333
});
330334
});
335+
336+
describe('mergeCategoriesBySlug', () => {
337+
it('should return categories unchanged when no duplicates', () => {
338+
const categories: CategoryConfig[] = [
339+
{ slug: 'bug-prevention', title: 'Bug prevention', refs: [] },
340+
{ slug: 'code-style', title: 'Code style', refs: [] },
341+
];
342+
expect(mergeCategoriesBySlug(categories)).toEqual(categories);
343+
});
344+
345+
it('should merge duplicate slugs — first title wins, refs concatenated', () => {
346+
expect(
347+
mergeCategoriesBySlug([
348+
{
349+
slug: 'bug-prevention',
350+
title: 'Bug prevention',
351+
refs: [
352+
{ type: 'group', plugin: 'eslint', slug: 'problems', weight: 1 },
353+
],
354+
},
355+
{
356+
slug: 'bug-prevention',
357+
title: 'Bug detection',
358+
refs: [
359+
{
360+
type: 'group',
361+
plugin: 'basic-plugin',
362+
slug: 'problems',
363+
weight: 1,
364+
},
365+
],
366+
},
367+
]),
368+
).toEqual([
369+
{
370+
slug: 'bug-prevention',
371+
title: 'Bug prevention',
372+
refs: [
373+
{ type: 'group', plugin: 'eslint', slug: 'problems', weight: 1 },
374+
{
375+
type: 'group',
376+
plugin: 'basic-plugin',
377+
slug: 'problems',
378+
weight: 1,
379+
},
380+
],
381+
},
382+
]);
383+
});
384+
385+
it('should join different descriptions as sentences', () => {
386+
expect(
387+
mergeCategoriesBySlug([
388+
{
389+
slug: 'bug-prevention',
390+
title: 'Bug prevention',
391+
description: 'Catches common bugs',
392+
refs: [],
393+
},
394+
{
395+
slug: 'bug-prevention',
396+
title: 'Bug prevention',
397+
description: 'Enforces type safety.',
398+
refs: [],
399+
},
400+
]),
401+
).toContainEqual(
402+
expect.objectContaining({
403+
description: 'Catches common bugs. Enforces type safety.',
404+
}),
405+
);
406+
});
407+
408+
it('should not duplicate identical descriptions', () => {
409+
expect(
410+
mergeCategoriesBySlug([
411+
{
412+
slug: 'code-style',
413+
title: 'Code style',
414+
description: 'Consistent formatting.',
415+
refs: [],
416+
},
417+
{
418+
slug: 'code-style',
419+
title: 'Code style',
420+
description: 'Consistent formatting.',
421+
refs: [],
422+
},
423+
]),
424+
).toContainEqual(
425+
expect.objectContaining({ description: 'Consistent formatting.' }),
426+
);
427+
});
428+
429+
it('should use first non-empty docsUrl', () => {
430+
expect(
431+
mergeCategoriesBySlug([
432+
{
433+
slug: 'bug-prevention',
434+
title: 'Bug prevention',
435+
docsUrl: 'https://eslint.org/rules',
436+
refs: [],
437+
},
438+
{
439+
slug: 'bug-prevention',
440+
title: 'Bug prevention',
441+
docsUrl: 'https://typescript-eslint.io/rules',
442+
refs: [],
443+
},
444+
]),
445+
).toContainEqual(
446+
expect.objectContaining({ docsUrl: 'https://eslint.org/rules' }),
447+
);
448+
});
449+
450+
it('should fall back to second value when first is missing', () => {
451+
expect(
452+
mergeCategoriesBySlug([
453+
{ slug: 'code-style', title: 'Code style', refs: [] },
454+
{
455+
slug: 'code-style',
456+
title: 'Code style',
457+
docsUrl: 'https://eslint.org/rules',
458+
description: 'Consistent formatting.',
459+
refs: [],
460+
},
461+
]),
462+
).toContainEqual(
463+
expect.objectContaining({
464+
docsUrl: 'https://eslint.org/rules',
465+
description: 'Consistent formatting.',
466+
}),
467+
);
468+
});
469+
470+
it('should return empty array for empty input', () => {
471+
expect(mergeCategoriesBySlug([])).toEqual([]);
472+
});
473+
});

0 commit comments

Comments
 (0)