@@ -2,48 +2,73 @@ import type { CategoryConfig, Group, PluginConfig } from '@code-pushup/models';
22import { LIGHTHOUSE_GROUP_SLUGS , LIGHTHOUSE_PLUGIN_SLUG } from './constants.js' ;
33import { orderSlug , shouldExpandForUrls } from './processing.js' ;
44import { LIGHTHOUSE_GROUPS } from './runner/constants.js' ;
5- import type { LighthouseGroupSlugs } from './types.js' ;
5+ import type { LighthouseContext , LighthouseGroupSlugs } from './types.js' ;
66import { isLighthouseGroupSlug } from './utils.js' ;
77
8+ /**
9+ * Expands and aggregates categories for multi-URL Lighthouse runs.
10+ *
11+ * - If user categories are provided, expands all refs (groups and audits) for each URL.
12+ * - If not, generates categories from plugin groups only.
13+ * - Assigns per-URL weights with correct precedence.
14+ *
15+ * @public
16+ * @param plugin - {@link PluginConfig} object with groups and context
17+ * @param categories - {@link CategoryConfig} optional user-defined categories
18+ * @returns {CategoryConfig[] } - expanded and agregated categories
19+ *
20+ * @example
21+ * const lhPlugin = await lighthousePlugin(urls);
22+ * const lhCoreConfig = {
23+ * plugins: [lhPlugin],
24+ * categories: mergeLighthouseCategories(lhPlugin),
25+ * };
26+ */
827export function mergeLighthouseCategories (
9- { groups } : Pick < PluginConfig , 'groups' > ,
28+ plugin : Pick < PluginConfig , 'groups' | 'context '> ,
1029 categories ?: CategoryConfig [ ] ,
1130) : CategoryConfig [ ] {
12- if ( ! groups ) {
31+ if ( ! plugin . groups || plugin . groups . length === 0 ) {
1332 return categories ?? [ ] ;
1433 }
34+ const validContext = validateContext ( plugin . context ) ;
1535 if ( ! categories ) {
16- return createCategories ( groups ) ;
36+ return createCategories ( plugin . groups , validContext ) ;
1737 }
18- return expandCategories ( categories , groups ) ;
38+ return expandCategories ( categories , validContext ) ;
1939}
2040
21- function createCategories ( groups : Group [ ] ) : CategoryConfig [ ] {
22- const urlCount = countUrls ( groups ) ;
23- if ( ! shouldExpandForUrls ( urlCount ) ) {
41+ function createCategories (
42+ groups : Group [ ] ,
43+ context : LighthouseContext ,
44+ ) : CategoryConfig [ ] {
45+ if ( ! shouldExpandForUrls ( context . urlCount ) ) {
2446 return [ ] ;
2547 }
26- return extractGroupSlugs ( groups )
27- . filter ( isLighthouseGroupSlug )
28- . map ( slug => createAggregatedCategory ( slug , urlCount ) ) ;
48+ return extractGroupSlugs ( groups ) . map ( slug =>
49+ createAggregatedCategory ( slug , context ) ,
50+ ) ;
2951}
3052
3153function expandCategories (
3254 categories : CategoryConfig [ ] ,
33- groups : Group [ ] ,
55+ context : LighthouseContext ,
3456) : CategoryConfig [ ] {
35- const urlCount = countUrls ( groups ) ;
36- if ( ! shouldExpandForUrls ( urlCount ) ) {
57+ if ( ! shouldExpandForUrls ( context . urlCount ) ) {
3758 return categories ;
3859 }
3960 return categories . map ( category =>
40- expandAggregatedCategory ( category , urlCount ) ,
61+ expandAggregatedCategory ( category , context ) ,
4162 ) ;
4263}
4364
65+ /**
66+ * Creates a category config for a Lighthouse group, expanding it for each URL.
67+ * Only used when user categories are not provided.
68+ */
4469export function createAggregatedCategory (
4570 groupSlug : LighthouseGroupSlugs ,
46- urlCount : number ,
71+ context : LighthouseContext ,
4772) : CategoryConfig {
4873 const group = LIGHTHOUSE_GROUPS . find ( ( { slug } ) => slug === groupSlug ) ;
4974 if ( ! group ) {
@@ -56,42 +81,79 @@ export function createAggregatedCategory(
5681 slug : group . slug ,
5782 title : group . title ,
5883 ...( group . description && { description : group . description } ) ,
59- refs : Array . from ( { length : urlCount } , ( _ , i ) => ( {
84+ refs : Array . from ( { length : context . urlCount } , ( _ , i ) => ( {
6085 plugin : LIGHTHOUSE_PLUGIN_SLUG ,
61- slug : orderSlug ( group . slug , i ) ,
86+ slug : shouldExpandForUrls ( context . urlCount )
87+ ? orderSlug ( group . slug , i )
88+ : group . slug ,
6289 type : 'group' ,
63- weight : 1 ,
90+ weight : resolveWeight ( context . weights , i ) ,
6491 } ) ) ,
6592 } ;
6693}
6794
95+ /**
96+ * Expands all refs (groups and audits) in a user-defined category for each URL.
97+ * Used when user categories are provided.
98+ */
6899export function expandAggregatedCategory (
69100 category : CategoryConfig ,
70- urlCount : number ,
101+ context : LighthouseContext ,
71102) : CategoryConfig {
72103 return {
73104 ...category ,
74105 refs : category . refs . flatMap ( ref => {
75106 if ( ref . plugin === LIGHTHOUSE_PLUGIN_SLUG ) {
76- return Array . from ( { length : urlCount } , ( _ , i ) => ( {
107+ return Array . from ( { length : context . urlCount } , ( _ , i ) => ( {
77108 ...ref ,
78- slug : orderSlug ( ref . slug , i ) ,
109+ slug : shouldExpandForUrls ( context . urlCount )
110+ ? orderSlug ( ref . slug , i )
111+ : ref . slug ,
112+ weight : resolveWeight ( context . weights , i , ref . weight ) ,
79113 } ) ) ;
80114 }
81115 return [ ref ] ;
82116 } ) ,
83117 } ;
84118}
85119
86- export function countUrls ( groups : Group [ ] ) : number {
87- const suffixes = groups
88- . map ( ( { slug } ) => slug . match ( / - ( \d + ) $ / ) ?. [ 1 ] )
89- . filter ( Boolean )
90- . map ( Number )
91- . filter ( n => ! Number . isNaN ( n ) ) ;
92- return suffixes . length > 0 ? Math . max ( ...suffixes ) : 1 ;
120+ /**
121+ * Extracts unique, unsuffixed group slugs from a list of groups.
122+ * Useful for deduplicating and normalizing group slugs when generating categories.
123+ */
124+ export function extractGroupSlugs ( groups : Group [ ] ) : LighthouseGroupSlugs [ ] {
125+ const slugs = groups . map ( ( { slug } ) => slug . replace ( / - \d + $ / , '' ) ) ;
126+ return [ ...new Set ( slugs ) ] . filter ( isLighthouseGroupSlug ) ;
127+ }
128+
129+ export class ContextValidationError extends Error {
130+ constructor ( message : string ) {
131+ super ( `Invalid Lighthouse context: ${ message } ` ) ;
132+ }
133+ }
134+
135+ export function validateContext (
136+ context : PluginConfig [ 'context' ] ,
137+ ) : LighthouseContext {
138+ if ( ! context || typeof context !== 'object' ) {
139+ throw new ContextValidationError ( 'must be an object' ) ;
140+ }
141+ if ( typeof context [ 'urlCount' ] !== 'number' || context [ 'urlCount' ] < 0 ) {
142+ throw new ContextValidationError ( 'urlCount must be a non-negative number' ) ;
143+ }
144+ if ( ! context [ 'weights' ] || typeof context [ 'weights' ] !== 'object' ) {
145+ throw new ContextValidationError ( 'weights must be an object' ) ;
146+ }
147+ if ( Object . keys ( context [ 'weights' ] ) . length !== context [ 'urlCount' ] ) {
148+ throw new ContextValidationError ( 'urlCount and weights length must match' ) ;
149+ }
150+ return context as LighthouseContext ;
93151}
94152
95- export function extractGroupSlugs ( groups : Group [ ] ) : string [ ] {
96- return [ ...new Set ( groups . map ( ( { slug } ) => slug . replace ( / - \d + $ / , '' ) ) ) ] ;
153+ function resolveWeight (
154+ weights : LighthouseContext [ 'weights' ] ,
155+ index : number ,
156+ userDefinedWeight ?: number ,
157+ ) : number {
158+ return weights [ index + 1 ] ?? userDefinedWeight ?? 1 ;
97159}
0 commit comments