lighthouse 12.6.1-dev.20250604 → 12.6.1-dev.20250606

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -61,6 +61,7 @@ import screenshot from './test-definitions/screenshot.js';
61
61
  import seoFailing from './test-definitions/seo-failing.js';
62
62
  import seoPassing from './test-definitions/seo-passing.js';
63
63
  import seoStatus403 from './test-definitions/seo-status-403.js';
64
+ import seoMixedLanguage from './test-definitions/seo-mixed-language.js';
64
65
  import serviceWorkerReloaded from './test-definitions/service-worker-reloaded.js';
65
66
  import shiftAttribution from './test-definitions/shift-attribution.js';
66
67
  import sourceMaps from './test-definitions/source-maps.js';
@@ -125,6 +126,7 @@ const smokeTests = [
125
126
  seoFailing,
126
127
  seoPassing,
127
128
  seoStatus403,
129
+ seoMixedLanguage,
128
130
  serviceWorkerReloaded,
129
131
  shiftAttribution,
130
132
  sourceMaps,
@@ -1,11 +1,11 @@
1
1
  export type CreateDetailsExtras = {
2
2
  insights: import("@paulirish/trace_engine/models/trace/insights/types.js").InsightSet;
3
- parsedTrace: LH.Artifacts.TraceEngineResult["data"];
3
+ parsedTrace: LH.Artifacts.TraceEngineResult["parsedTrace"];
4
4
  };
5
5
  /**
6
6
  * @typedef CreateDetailsExtras
7
7
  * @property {import('@paulirish/trace_engine/models/trace/insights/types.js').InsightSet} insights
8
- * @property {LH.Artifacts.TraceEngineResult['data']} parsedTrace
8
+ * @property {LH.Artifacts.TraceEngineResult['parsedTrace']} parsedTrace
9
9
  */
10
10
  /**
11
11
  * @param {LH.Artifacts} artifacts
@@ -16,7 +16,7 @@ const str_ = i18n.createIcuMessageFn(import.meta.url, {});
16
16
  /**
17
17
  * @param {LH.Artifacts} artifacts
18
18
  * @param {LH.Audit.Context} context
19
- * @return {Promise<{insights: import('@paulirish/trace_engine/models/trace/insights/types.js').InsightSet|undefined, parsedTrace: LH.Artifacts.TraceEngineResult['data']}>}
19
+ * @return {Promise<{insights: import('@paulirish/trace_engine/models/trace/insights/types.js').InsightSet|undefined, parsedTrace: LH.Artifacts.TraceEngineResult['parsedTrace']}>}
20
20
  */
21
21
  async function getInsightSet(artifacts, context) {
22
22
  const settings = context.settings;
@@ -29,13 +29,13 @@ async function getInsightSet(artifacts, context) {
29
29
  const key = navigationId ?? NO_NAVIGATION;
30
30
  const insights = traceEngineResult.insights.get(key);
31
31
 
32
- return {insights, parsedTrace: traceEngineResult.data};
32
+ return {insights, parsedTrace: traceEngineResult.parsedTrace};
33
33
  }
34
34
 
35
35
  /**
36
36
  * @typedef CreateDetailsExtras
37
37
  * @property {import('@paulirish/trace_engine/models/trace/insights/types.js').InsightSet} insights
38
- * @property {LH.Artifacts.TraceEngineResult['data']} parsedTrace
38
+ * @property {LH.Artifacts.TraceEngineResult['parsedTrace']} parsedTrace
39
39
  */
40
40
 
41
41
  /**
@@ -62,7 +62,7 @@ class LayoutShifts extends Audit {
62
62
  const SourceMaps = artifacts.SourceMaps;
63
63
  const traceEngineResult =
64
64
  await TraceEngineResult.request({trace, settings, SourceMaps}, context);
65
- const clusters = traceEngineResult.data.LayoutShifts.clusters ?? [];
65
+ const clusters = traceEngineResult.parsedTrace.LayoutShifts.clusters ?? [];
66
66
  const {cumulativeLayoutShift: clsSavings, impactByNodeId} =
67
67
  await CumulativeLayoutShiftComputed.request(trace, context);
68
68
  const traceElements = artifacts.TraceElements
@@ -8,95 +8,123 @@ import {Audit} from '../audit.js';
8
8
  import UrlUtils from '../../lib/url-utils.js';
9
9
  import * as i18n from '../../lib/i18n/i18n.js';
10
10
 
11
- const BLOCKLIST = new Set([
11
+ /** @type {Record<string, Set<string>>} */
12
+ const nonDescriptiveLinkTexts = {
12
13
  // English
13
- 'click here',
14
- 'click this',
15
- 'go',
16
- 'here',
17
- 'information',
18
- 'learn more',
19
- 'more',
20
- 'more info',
21
- 'more information',
22
- 'right here',
23
- 'read more',
24
- 'see more',
25
- 'start',
26
- 'this',
14
+ 'en': new Set([
15
+ 'click here',
16
+ 'click this',
17
+ 'go',
18
+ 'here',
19
+ 'information',
20
+ 'learn more',
21
+ 'more',
22
+ 'more info',
23
+ 'more information',
24
+ 'right here',
25
+ 'read more',
26
+ 'see more',
27
+ 'start',
28
+ 'this',
29
+ ]),
27
30
  // Japanese
28
- 'ここをクリック',
29
- 'こちらをクリック',
30
- 'リンク',
31
- '続きを読む',
32
- '続く',
33
- '全文表示',
31
+ 'ja': new Set([
32
+ 'ここをクリック',
33
+ 'こちらをクリック',
34
+ 'リンク',
35
+ '続きを読む',
36
+ '続く',
37
+ '全文表示',
38
+ ]),
34
39
  // Spanish
35
- 'click aquí',
36
- 'click aqui',
37
- 'clicka aquí',
38
- 'clicka aqui',
39
- 'pincha aquí',
40
- 'pincha aqui',
41
- 'aquí',
42
- 'aqui',
43
- 'más',
44
- 'mas',
45
- 'más información',
46
- 'más informacion',
47
- 'mas información',
48
- 'mas informacion',
49
- 'este',
50
- 'enlace',
51
- 'este enlace',
52
- 'empezar',
40
+ 'es': new Set([
41
+ 'click aquí',
42
+ 'click aqui',
43
+ 'clicka aquí',
44
+ 'clicka aqui',
45
+ 'pincha aquí',
46
+ 'pincha aqui',
47
+ 'aquí',
48
+ 'aqui',
49
+ 'más',
50
+ 'mas',
51
+ 'más información',
52
+ 'más informacion',
53
+ 'mas información',
54
+ 'mas informacion',
55
+ 'este',
56
+ 'enlace',
57
+ 'este enlace',
58
+ 'empezar',
59
+ ]),
53
60
  // Portuguese
54
- 'clique aqui',
55
- 'ir',
56
- 'mais informação',
57
- 'mais informações',
58
- 'mais',
59
- 'veja mais',
61
+ 'pt': new Set([
62
+ 'clique aqui',
63
+ 'ir',
64
+ 'mais informação',
65
+ 'mais informações',
66
+ 'mais',
67
+ 'veja mais',
68
+ ]),
60
69
  // Korean
61
- '여기',
62
- '여기를 클릭',
63
- '클릭',
64
- '링크',
65
- '자세히',
66
- '자세히 보기',
67
- '계속',
68
- '이동',
69
- '전체 보기',
70
+ 'ko': new Set([
71
+ '여기',
72
+ '여기를 클릭',
73
+ '클릭',
74
+ '링크',
75
+ '자세히',
76
+ '자세히 보기',
77
+ '계속',
78
+ '이동',
79
+ '전체 보기',
80
+ ]),
70
81
  // Swedish
71
- 'här',
72
- 'klicka här',
73
- 'läs mer',
74
- 'mer',
75
- 'mer info',
76
- 'mer information',
82
+ 'sv': new Set([
83
+ 'här',
84
+ 'klicka här',
85
+ 'läs mer',
86
+ 'mer',
87
+ 'mer info',
88
+ 'mer information',
89
+ ]),
90
+ // German
91
+ 'de': new Set([
92
+ 'klicke hier',
93
+ 'hier klicken',
94
+ 'hier',
95
+ 'mehr',
96
+ 'siehe',
97
+ 'dies',
98
+ 'das',
99
+ 'weiterlesen',
100
+ ]),
77
101
  // Tamil
78
- 'அடுத்த பக்கம்',
79
- 'மறுபக்கம்',
80
- 'முந்தைய பக்கம்',
81
- 'முன்பக்கம்',
82
- 'மேலும் அறிக',
83
- 'மேலும் தகவலுக்கு',
84
- 'மேலும் தரவுகளுக்கு',
85
- 'தயவுசெய்து இங்கே அழுத்தவும்',
86
- 'இங்கே கிளிக் செய்யவும்',
102
+ 'ta': new Set([
103
+ 'அடுத்த பக்கம்',
104
+ 'மறுபக்கம்',
105
+ 'முந்தைய பக்கம்',
106
+ 'முன்பக்கம்',
107
+ 'மேலும் அறிக',
108
+ 'மேலும் தகவலுக்கு',
109
+ 'மேலும் தரவுகளுக்கு',
110
+ 'தயவுசெய்து இங்கே அழுத்தவும்',
111
+ 'இங்கே கிளிக் செய்யவும்',
112
+ ]),
87
113
  // Persian
88
- 'اطلاعات بیشتر',
89
- 'اطلاعات',
90
- 'این',
91
- 'اینجا بزنید',
92
- 'اینجا کلیک کنید',
93
- 'اینجا',
94
- 'برو',
95
- 'بیشتر بخوانید',
96
- 'بیشتر بدانید',
97
- 'بیشتر',
98
- 'شروع',
99
- ]);
114
+ 'fa': new Set([
115
+ 'اطلاعات بیشتر',
116
+ 'اطلاعات',
117
+ 'این',
118
+ 'اینجا بزنید',
119
+ 'اینجا کلیک کنید',
120
+ 'اینجا',
121
+ 'برو',
122
+ 'بیشتر بخوانید',
123
+ 'بیشتر بدانید',
124
+ 'بیشتر',
125
+ 'شروع',
126
+ ]),
127
+ };
100
128
 
101
129
  const UIStrings = {
102
130
  /** Title of a Lighthouse audit that tests if each link on a page contains a sufficient description of what a user will find when they click it. Generic, non-descriptive text like "click here" doesn't give an indication of what the link leads to. This descriptive title is shown when all links on the page have sufficient textual descriptions. */
@@ -135,8 +163,9 @@ class LinkText extends Audit {
135
163
  */
136
164
  static audit(artifacts) {
137
165
  const failingLinks = artifacts.AnchorElements
138
- .filter(link => link.href && !link.rel.includes('nofollow'))
139
166
  .filter(link => {
167
+ if (!link.href || link.rel.includes('nofollow')) return false;
168
+
140
169
  const href = link.href.toLowerCase();
141
170
  if (
142
171
  href.startsWith('javascript:') ||
@@ -148,12 +177,30 @@ class LinkText extends Audit {
148
177
  return false;
149
178
  }
150
179
 
151
- return BLOCKLIST.has(link.text.trim().toLowerCase());
180
+ const searchTerm = link.text.trim().toLowerCase();
181
+ if (searchTerm) {
182
+ // Use language if detected, otherwise look at everything.
183
+ if (link.textLang) {
184
+ const lang = link.textLang.split('-')[0];
185
+ if (nonDescriptiveLinkTexts[lang] && nonDescriptiveLinkTexts[lang].has(searchTerm)) {
186
+ return true;
187
+ }
188
+ } else {
189
+ for (const texts of Object.values(nonDescriptiveLinkTexts)) {
190
+ if (texts.has(searchTerm)) {
191
+ return true;
192
+ }
193
+ }
194
+ }
195
+ }
196
+
197
+ return false;
152
198
  })
153
199
  .map(link => {
154
200
  return {
155
201
  href: link.href,
156
202
  text: link.text.trim(),
203
+ textLang: link.textLang,
157
204
  };
158
205
  });
159
206
 
@@ -38,14 +38,15 @@ async function getComputationDataParamsFromTrace(data, context) {
38
38
 
39
39
  const graph = await PageDependencyGraph.request({...data, fromTrace: true}, context);
40
40
  const traceEngineResult = await TraceEngineResult.request(data, context);
41
- const frameId = traceEngineResult.data.Meta.mainFrameId;
42
- const navigationId = traceEngineResult.data.Meta.mainFrameNavigations[0].args.data?.navigationId;
41
+ const frameId = traceEngineResult.parsedTrace.Meta.mainFrameId;
42
+ const navigationId =
43
+ traceEngineResult.parsedTrace.Meta.mainFrameNavigations[0].args.data?.navigationId;
43
44
  if (!navigationId) {
44
45
  throw new Error(`Lantern metrics could not be calculated due to missing navigation id`);
45
46
  }
46
47
 
47
48
  const processedNavigation = Lantern.TraceEngineComputationData.createProcessedNavigation(
48
- traceEngineResult.data, frameId, navigationId);
49
+ traceEngineResult.parsedTrace, frameId, navigationId);
49
50
  const simulator = data.simulator || (await LoadSimulator.request(data, context));
50
51
 
51
52
  return {simulator, graph, processedNavigation};
@@ -27,11 +27,11 @@ class PageDependencyGraph {
27
27
  if (data.fromTrace) {
28
28
  const traceEngineResult =
29
29
  await TraceEngineResult.request({trace, settings, SourceMaps}, context);
30
- const traceEngineData = traceEngineResult.data;
30
+ const parsedTrace = traceEngineResult.parsedTrace;
31
31
  const requests =
32
- Lantern.TraceEngineComputationData.createNetworkRequests(trace, traceEngineData);
32
+ Lantern.TraceEngineComputationData.createNetworkRequests(trace, parsedTrace);
33
33
  const graph =
34
- Lantern.TraceEngineComputationData.createGraph(requests, trace, traceEngineData, URL);
34
+ Lantern.TraceEngineComputationData.createGraph(requests, trace, parsedTrace, URL);
35
35
  // @ts-expect-error for now, ignore that this is a SyntheticNetworkEvent instead of LH's NetworkEvent.
36
36
  return graph;
37
37
  }
@@ -63,7 +63,7 @@ class TraceEngineResult {
63
63
  if (!processor.parsedTrace) throw new Error('No data');
64
64
  if (!processor.insights) throw new Error('No insights');
65
65
  this.localizeInsights(processor.insights);
66
- return {data: processor.parsedTrace, insights: processor.insights};
66
+ return {parsedTrace: processor.parsedTrace, insights: processor.insights};
67
67
  }
68
68
 
69
69
  /**
@@ -38,10 +38,49 @@ function collectAnchorElements() {
38
38
  return onclick.slice(0, 1024);
39
39
  }
40
40
 
41
+ /**
42
+ * @param {HTMLElement|SVGElement} node
43
+ * @return {string|null}
44
+ */
45
+ function getLangOfInnerText(node) {
46
+ let curNodeLang = null;
47
+
48
+ // If we find multiple languages within this element, return null.
49
+ for (const child of node.querySelectorAll('*')) {
50
+ if (!child.textContent) continue;
51
+
52
+ const childLang = child.closest('[lang]')?.getAttribute('lang');
53
+ if (!childLang) continue;
54
+
55
+ if (!curNodeLang) {
56
+ curNodeLang = childLang;
57
+ continue;
58
+ }
59
+
60
+ if (curNodeLang.split('-')[0] !== childLang.split('-')[0]) {
61
+ return null;
62
+ }
63
+ }
64
+
65
+ return curNodeLang ?? node.closest('[lang]')?.getAttribute('lang') ?? null;
66
+ }
67
+
41
68
  /** @type {Array<HTMLAnchorElement|SVGAElement>} */
42
69
  // @ts-expect-error - put into scope via stringification
43
70
  const anchorElements = getElementsInDocument('a'); // eslint-disable-line no-undef
44
71
 
72
+ // Check, if document has only one lang attribute in opening html or in body tag. If so,
73
+ // there is no need to run the `getLangOfInnerText()` function with multiple
74
+ // possible DOM traversals
75
+ /** @type {Array<HTMLElement|SVGElement>} */
76
+ // @ts-expect-error - put into scope via stringification
77
+ const langElements = getElementsInDocument('[lang]'); // eslint-disable-line no-undef
78
+ const documentHasSingleLang = langElements.length === 1 &&
79
+ (langElements[0].nodeName === 'BODY' || langElements[0].nodeName === 'HTML');
80
+ const singleLang = documentHasSingleLang ? langElements[0].getAttribute('lang') : null;
81
+
82
+ // TODO: consider Content-Language.
83
+
45
84
  return anchorElements.map(node => {
46
85
  if (node instanceof HTMLAnchorElement) {
47
86
  return {
@@ -51,6 +90,7 @@ function collectAnchorElements() {
51
90
  role: node.getAttribute('role') || '',
52
91
  name: node.name,
53
92
  text: node.innerText, // we don't want to return hidden text, so use innerText
93
+ textLang: singleLang ?? getLangOfInnerText(node) ?? undefined,
54
94
  rel: node.rel,
55
95
  target: node.target,
56
96
  id: node.getAttribute('id') || '',
@@ -65,6 +105,7 @@ function collectAnchorElements() {
65
105
  onclick: getTruncatedOnclick(node),
66
106
  role: node.getAttribute('role') || '',
67
107
  text: node.textContent || '',
108
+ textLang: singleLang ?? getLangOfInnerText(node) ?? undefined,
68
109
  rel: '',
69
110
  target: node.target.baseVal || '',
70
111
  id: node.getAttribute('id') || '',
@@ -32,11 +32,11 @@ declare class TraceElements extends BaseGatherer {
32
32
  * that may have caused the shift.
33
33
  *
34
34
  * @param {LH.Trace} trace
35
- * @param {LH.Artifacts.TraceEngineResult['data']} traceEngineResult
35
+ * @param {LH.Artifacts.TraceEngineResult['parsedTrace']} traceEngineResult
36
36
  * @param {LH.Gatherer.Context} context
37
37
  * @return {Promise<Array<{nodeId: number}>>}
38
38
  */
39
- static getTopLayoutShifts(trace: LH.Trace, traceEngineResult: LH.Artifacts.TraceEngineResult["data"], context: LH.Gatherer.Context): Promise<Array<{
39
+ static getTopLayoutShifts(trace: LH.Trace, traceEngineResult: LH.Artifacts.TraceEngineResult["parsedTrace"], context: LH.Gatherer.Context): Promise<Array<{
40
40
  nodeId: number;
41
41
  }>>;
42
42
  /**
@@ -173,7 +173,7 @@ class TraceElements extends BaseGatherer {
173
173
  * that may have caused the shift.
174
174
  *
175
175
  * @param {LH.Trace} trace
176
- * @param {LH.Artifacts.TraceEngineResult['data']} traceEngineResult
176
+ * @param {LH.Artifacts.TraceEngineResult['parsedTrace']} traceEngineResult
177
177
  * @param {LH.Gatherer.Context} context
178
178
  * @return {Promise<Array<{nodeId: number}>>}
179
179
  */
@@ -369,7 +369,7 @@ class TraceElements extends BaseGatherer {
369
369
  traceEngineResult, navigationId);
370
370
  const lcpNodeData = await TraceElements.getLcpElement(trace, context);
371
371
  const shiftsData = await TraceElements.getTopLayoutShifts(
372
- trace, traceEngineResult.data, context);
372
+ trace, traceEngineResult.parsedTrace, context);
373
373
  const animatedElementData = await this.getAnimatedElements(mainThreadEvents);
374
374
  const responsivenessElementData = await TraceElements.getResponsivenessElement(trace, context);
375
375
 
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "lighthouse",
3
3
  "type": "module",
4
- "version": "12.6.1-dev.20250604",
4
+ "version": "12.6.1-dev.20250606",
5
5
  "description": "Automated auditing, performance metrics, and best practices for the web.",
6
6
  "main": "./core/index.js",
7
7
  "bin": {
@@ -106,7 +106,7 @@
106
106
  "@esbuild-kit/esm-loader": "^2.1.1",
107
107
  "@esbuild-plugins/node-modules-polyfill": "^0.1.4",
108
108
  "@formatjs/icu-messageformat-parser": "^2.6.2",
109
- "@jest/fake-timers": "^28.1.0",
109
+ "@jest/fake-timers": "^29.7.0",
110
110
  "@testing-library/preact": "^3.1.1",
111
111
  "@testing-library/preact-hooks": "^1.1.0",
112
112
  "@types/archiver": "^2.1.2",
@@ -160,8 +160,8 @@
160
160
  "gh-pages": "^2.0.1",
161
161
  "glob": "^7.1.3",
162
162
  "idb-keyval": "2.2.0",
163
- "jest-mock": "^27.3.0",
164
- "jest-snapshot": "^28.1.0",
163
+ "jest-mock": "^29.7.0",
164
+ "jest-snapshot": "^29.7.0",
165
165
  "jsdom": "^12.2.0",
166
166
  "lighthouse-plugin-soft-navigation": "^1.0.1",
167
167
  "magic-string": "^0.25.7",
@@ -357,6 +357,7 @@ declare module Artifacts {
357
357
  rawHref: string
358
358
  name?: string
359
359
  text: string
360
+ textLang?: string
360
361
  role: string
361
362
  target: string
362
363
  node: NodeDetails
@@ -507,7 +508,7 @@ declare module Artifacts {
507
508
  }
508
509
 
509
510
  interface TraceEngineResult {
510
- data: TraceEngine.Handlers.Types.ParsedTrace;
511
+ parsedTrace: TraceEngine.Handlers.Types.ParsedTrace;
511
512
  insights: TraceEngine.Insights.Types.TraceInsightSets;
512
513
  }
513
514
 
@@ -6,7 +6,7 @@
6
6
 
7
7
  declare global {
8
8
  var expect: import('expect').Expect;
9
- type Mock<T, Y extends unknown[]> = import('jest-mock').Mock<T, Y>;
9
+ type Mock<T> = import('jest-mock').Mock<(...args: any) => T>;
10
10
  }
11
11
 
12
12
  declare module 'expect' {