Skip to content

Commit ba90832

Browse files
committed
feat(plugin-lighthouse): support multiple weighted URLs
1 parent d9a177e commit ba90832

8 files changed

Lines changed: 641 additions & 267 deletions

File tree

packages/models/src/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,8 +67,10 @@ export {
6767
} from './lib/persist-config.js';
6868
export {
6969
pluginConfigSchema,
70+
pluginContextSchema,
7071
pluginMetaSchema,
7172
type PluginConfig,
73+
type PluginContext,
7274
type PluginMeta,
7375
} from './lib/plugin-config.js';
7476
export {

packages/models/src/lib/plugin-config.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,12 @@ import {
1010
import { errorItems, hasMissingStrings } from './implementation/utils.js';
1111
import { runnerConfigSchema, runnerFunctionSchema } from './runner-config.js';
1212

13+
export const pluginContextSchema = z
14+
.record(z.unknown())
15+
.optional()
16+
.describe('Plugin-specific context data for helpers');
17+
export type PluginContext = z.infer<typeof pluginContextSchema>;
18+
1319
export const pluginMetaSchema = packageVersionSchema()
1420
.merge(
1521
metaSchema({
@@ -31,6 +37,7 @@ export const pluginDataSchema = z.object({
3137
runner: z.union([runnerConfigSchema, runnerFunctionSchema]),
3238
audits: pluginAuditsSchema,
3339
groups: groupsSchema,
40+
context: pluginContextSchema,
3441
});
3542
type PluginData = z.infer<typeof pluginDataSchema>;
3643

packages/plugin-lighthouse/src/lib/lighthouse-plugin.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ export function lighthousePlugin(
1313
const { skipAudits, onlyAudits, onlyCategories, ...unparsedFlags } =
1414
normalizeFlags(flags ?? {});
1515

16-
const normalizedUrls = normalizeUrlInput(urls);
16+
const { urls: normalizedUrls, context } = normalizeUrlInput(urls);
1717

1818
const { audits, groups } = processAuditsAndGroups(normalizedUrls, {
1919
skipAudits,
@@ -39,5 +39,6 @@ export function lighthousePlugin(
3939
onlyCategories,
4040
...unparsedFlags,
4141
}),
42+
context,
4243
};
4344
}

packages/plugin-lighthouse/src/lib/merge-categories.ts

Lines changed: 93 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -2,48 +2,73 @@ import type { CategoryConfig, Group, PluginConfig } from '@code-pushup/models';
22
import { LIGHTHOUSE_GROUP_SLUGS, LIGHTHOUSE_PLUGIN_SLUG } from './constants.js';
33
import { orderSlug, shouldExpandForUrls } from './processing.js';
44
import { LIGHTHOUSE_GROUPS } from './runner/constants.js';
5-
import type { LighthouseGroupSlugs } from './types.js';
5+
import type { LighthouseContext, LighthouseGroupSlugs } from './types.js';
66
import { 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+
*/
827
export 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

3153
function 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+
*/
4469
export 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+
*/
6899
export 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

Comments
 (0)