├── .github ├── FUNDING.yml └── workflows │ ├── test.yml │ ├── release.yml │ └── demo.yml ├── docs ├── 1.guide │ ├── .navigation.yml │ ├── recipes │ │ ├── .navigation.yml │ │ ├── spa.md │ │ ├── client.md │ │ └── improving-accuracy.md │ ├── guides │ │ ├── .navigation.yml │ │ ├── route-definitions.md │ │ ├── device.md │ │ ├── lighthouse.md │ │ ├── docker.md │ │ ├── common-errors.md │ │ ├── chrome-dependency.md │ │ ├── dynamic-sampling.md │ │ ├── puppeteer.md │ │ └── url-discovery.md │ └── 1.getting-started │ │ ├── .navigation.yml │ │ ├── 1.integrations.md │ │ └── 0.unlighthouse-cli.md ├── 3.api-doc │ └── .navigation.yml ├── integration-deprecations.md └── 2.integrations │ ├── webpack.md │ ├── 3.nuxt.md │ └── 4.vite.md ├── packages ├── core │ ├── src │ │ ├── data │ │ │ ├── index.ts │ │ │ └── scanMeta.ts │ │ ├── puppeteer │ │ │ ├── tasks │ │ │ │ ├── index.ts │ │ │ │ └── userFlow.ts │ │ │ ├── index.ts │ │ │ ├── cluster.ts │ │ │ └── util.ts │ │ ├── router │ │ │ ├── index.ts │ │ │ ├── mockVueRouter.ts │ │ │ ├── mockRouter.ts │ │ │ └── broadcasting.ts │ │ ├── discovery │ │ │ ├── index.ts │ │ │ ├── sitemap.ts │ │ │ └── routeDefinitions.ts │ │ ├── types │ │ │ └── puppeteer.ts │ │ ├── index.ts │ │ ├── logger.ts │ │ ├── lighthouse.ts │ │ ├── process │ │ │ └── lighthouse.ts │ │ └── util │ │ │ ├── cliFormatting.ts │ │ │ ├── filter.ts │ │ │ └── progressBox.ts │ ├── .attw.json │ ├── tsconfig.json │ ├── build.config.ts │ ├── vendor.d.ts │ ├── test │ │ └── filters.test.ts │ ├── README.md │ └── package.json ├── unlighthouse │ ├── src │ │ └── index.ts │ ├── .attw.json │ ├── bin │ │ ├── unlighthouse.mjs │ │ └── unlighthouse-ci.mjs │ ├── config.js │ ├── config.mjs │ ├── config.cjs │ ├── types.d.ts │ ├── build.config.ts │ ├── types.d.mts │ ├── config.d.mts │ ├── package.json │ └── README.md ├── unlighthouse-ci │ ├── src │ │ └── index.ts │ ├── bin │ │ ├── unlighthouse.cjs │ │ ├── unlighthouse.mjs │ │ ├── unlighthouse-ci.cjs │ │ └── unlighthouse-ci.mjs │ ├── index.d.ts │ ├── build.config.ts │ ├── package.json │ └── README.md ├── cli │ ├── bin │ │ ├── unlighthouse-ci.cjs │ │ └── unlighthouse-ci.mjs │ ├── vendor.d.ts │ ├── build.config.ts │ ├── src │ │ ├── errors.ts │ │ ├── reporters │ │ │ ├── jsonSimple.ts │ │ │ ├── csvSimple.ts │ │ │ ├── types.ts │ │ │ ├── csvExpanded.ts │ │ │ ├── lighthouseServer.ts │ │ │ └── index.ts │ │ ├── types.ts │ │ ├── cli.ts │ │ └── createCli.ts │ ├── package.json │ ├── README.md │ └── test │ │ ├── csv-reports.test.ts │ │ └── lighthouseServer-reports.test.ts ├── client │ ├── components │ │ ├── Container.vue │ │ ├── Chip │ │ │ ├── InfoChip.vue │ │ │ ├── ErrorChip.vue │ │ │ └── WarningChip.vue │ │ ├── Badge.vue │ │ ├── Card.vue │ │ ├── Results │ │ │ ├── ResultsPanel.vue │ │ │ ├── ResultsRow.vue │ │ │ ├── ResultsRoute.vue │ │ │ ├── ResultsTableHead.vue │ │ │ └── ResultsCell.vue │ │ ├── Loading │ │ │ ├── LoadingSpinner.vue │ │ │ └── LoadingStatusIcon.vue │ │ ├── Btn │ │ │ ├── BtnAction.vue │ │ │ ├── BtnBasic.vue │ │ │ ├── BtnTab.vue │ │ │ └── BtnIcon.vue │ │ ├── Audit │ │ │ ├── AuditResultItemsLength.vue │ │ │ └── AuditResult.vue │ │ ├── Cell │ │ │ ├── CellTapTargets.vue │ │ │ ├── CellIndexable.vue │ │ │ ├── CellScreenshotThumbnails.vue │ │ │ ├── CellColorContrast.vue │ │ │ ├── CellImageIssues.vue │ │ │ ├── CellMetaDescription.vue │ │ │ ├── CellScoresOverview.vue │ │ │ ├── CellLargestContentfulPaint.vue │ │ │ ├── CellImage.vue │ │ │ ├── CellLayoutShift.vue │ │ │ ├── CellScoreSingle.vue │ │ │ ├── CellWebVitals.vue │ │ │ └── CellNetworkRequests.vue │ │ ├── StatusChip.vue │ │ ├── SearchBox.vue │ │ ├── Card │ │ │ ├── CardPackages.vue │ │ │ ├── CardRouteScanProgress.vue │ │ │ └── CardModuleSizes.vue │ │ ├── StatItem.vue │ │ ├── Tooltip.vue │ │ ├── ModalTrigger.vue │ │ ├── Disclosure │ │ │ └── DisclosureHandle.vue │ │ ├── AuditResultWithTooltip.vue │ │ └── Popover │ │ │ └── PopoverActions.vue │ ├── public │ │ └── assets │ │ │ ├── lighthouse.fbx │ │ │ ├── logo-light.svg │ │ │ ├── logo.svg │ │ │ └── logo-dark.svg │ ├── logic │ │ ├── index.ts │ │ ├── fetch.ts │ │ ├── actions │ │ │ └── rescanSite.ts │ │ ├── dark.ts │ │ ├── util.ts │ │ ├── formatting.ts │ │ └── offline.ts │ ├── main.ts │ ├── constants.ts │ ├── types.d.ts │ ├── tsconfig.json │ ├── README.md │ ├── package.json │ ├── index.html │ └── vite.config.ts └── server │ ├── build.config.ts │ ├── src │ └── index.ts │ ├── README.md │ └── package.json ├── crux-api ├── .npmrc ├── .gitignore ├── README.md ├── tsconfig.json ├── package.json ├── nitro.config.ts └── server │ └── api │ └── [domain] │ └── crux │ └── history.get.ts ├── .npmrc ├── .editorconfig ├── test ├── fixtures │ ├── react-beta.config.ts │ ├── harlanzw-json-expanded.config.ts │ ├── harlanzw.config.ts │ └── staging-vue.config.ts ├── types.test.ts ├── ci.test.ts └── cli.test.ts ├── eslint.config.js ├── vitest.config.ts ├── LICENSE.md ├── tsconfig.json ├── package.json ├── .gitignore ├── README.md └── pnpm-workspace.yaml /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | github: [harlan-zw] 2 | -------------------------------------------------------------------------------- /docs/1.guide/.navigation.yml: -------------------------------------------------------------------------------- 1 | title: Guide 2 | -------------------------------------------------------------------------------- /docs/3.api-doc/.navigation.yml: -------------------------------------------------------------------------------- 1 | title: API 2 | -------------------------------------------------------------------------------- /packages/core/src/data/index.ts: -------------------------------------------------------------------------------- 1 | export * from './scanMeta' 2 | -------------------------------------------------------------------------------- /packages/unlighthouse/src/index.ts: -------------------------------------------------------------------------------- 1 | export * from '@unlighthouse/core' 2 | -------------------------------------------------------------------------------- /crux-api/.npmrc: -------------------------------------------------------------------------------- 1 | shamefully-hoist=true 2 | strict-peer-dependencies=false 3 | -------------------------------------------------------------------------------- /docs/1.guide/recipes/.navigation.yml: -------------------------------------------------------------------------------- 1 | title: Recipes 2 | icon: i-noto-cook 3 | -------------------------------------------------------------------------------- /packages/unlighthouse-ci/src/index.ts: -------------------------------------------------------------------------------- 1 | export * from '@unlighthouse/core' 2 | -------------------------------------------------------------------------------- /docs/1.guide/guides/.navigation.yml: -------------------------------------------------------------------------------- 1 | title: Guides 2 | icon: i-noto-open-book 3 | -------------------------------------------------------------------------------- /packages/core/.attw.json: -------------------------------------------------------------------------------- 1 | { 2 | "ignoreRules": ["cjs-resolves-to-esm"] 3 | } 4 | -------------------------------------------------------------------------------- /packages/cli/bin/unlighthouse-ci.cjs: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | import('../dist/ci.mjs') 3 | -------------------------------------------------------------------------------- /packages/cli/bin/unlighthouse-ci.mjs: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | import('../dist/ci.mjs') 3 | -------------------------------------------------------------------------------- /packages/unlighthouse/.attw.json: -------------------------------------------------------------------------------- 1 | { 2 | "ignoreRules": ["cjs-resolves-to-esm"] 3 | } 4 | -------------------------------------------------------------------------------- /crux-api/.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | dist 3 | .data 4 | .nitro 5 | .cache 6 | .output 7 | .env 8 | -------------------------------------------------------------------------------- /docs/1.guide/1.getting-started/.navigation.yml: -------------------------------------------------------------------------------- 1 | title: Getting Started 2 | icon: i-noto-star 3 | -------------------------------------------------------------------------------- /packages/unlighthouse/bin/unlighthouse.mjs: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | import '@unlighthouse/cli' 3 | -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- 1 | ignore-workspace-root-check=true 2 | shamefully-hoist=true 3 | link-workspace-packages=false 4 | -------------------------------------------------------------------------------- /crux-api/README.md: -------------------------------------------------------------------------------- 1 | # CrUX API 2 | 3 | Used to get the CrUX historical data for a given origin. 4 | -------------------------------------------------------------------------------- /packages/core/src/puppeteer/tasks/index.ts: -------------------------------------------------------------------------------- 1 | export * from './html' 2 | export * from './lighthouse' 3 | -------------------------------------------------------------------------------- /packages/unlighthouse-ci/bin/unlighthouse.cjs: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | import('@unlighthouse/cli') 3 | -------------------------------------------------------------------------------- /packages/unlighthouse-ci/bin/unlighthouse.mjs: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | import('@unlighthouse/cli') 3 | -------------------------------------------------------------------------------- /packages/unlighthouse/bin/unlighthouse-ci.mjs: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | import '@unlighthouse/cli/ci' 3 | -------------------------------------------------------------------------------- /packages/unlighthouse-ci/bin/unlighthouse-ci.cjs: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | import('@unlighthouse/cli/ci') 3 | -------------------------------------------------------------------------------- /packages/unlighthouse-ci/bin/unlighthouse-ci.mjs: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | import('@unlighthouse/cli/ci') 3 | -------------------------------------------------------------------------------- /packages/unlighthouse-ci/index.d.ts: -------------------------------------------------------------------------------- 1 | export * from './dist/index' 2 | export { default } from './dist/index' 3 | -------------------------------------------------------------------------------- /packages/core/src/puppeteer/index.ts: -------------------------------------------------------------------------------- 1 | export * from './cluster' 2 | export * from './tasks' 3 | export * from './worker' 4 | -------------------------------------------------------------------------------- /crux-api/tsconfig.json: -------------------------------------------------------------------------------- 1 | // https://nitro.unjs.io/guide/typescript 2 | { 3 | "extends": "./.nitro/types/tsconfig.json" 4 | } 5 | -------------------------------------------------------------------------------- /packages/client/components/Container.vue: -------------------------------------------------------------------------------- 1 | 6 | -------------------------------------------------------------------------------- /packages/cli/vendor.d.ts: -------------------------------------------------------------------------------- 1 | declare module 'better-opn' { 2 | function launch(file: string): Promise 3 | export default launch 4 | } 5 | -------------------------------------------------------------------------------- /packages/client/components/Chip/InfoChip.vue: -------------------------------------------------------------------------------- 1 | 6 | -------------------------------------------------------------------------------- /packages/client/public/assets/lighthouse.fbx: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/harlan-zw/unlighthouse/HEAD/packages/client/public/assets/lighthouse.fbx -------------------------------------------------------------------------------- /packages/core/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../tsconfig.json", 3 | "exclude": [ 4 | "node_modules", 5 | "dist" 6 | ] 7 | } 8 | -------------------------------------------------------------------------------- /packages/client/components/Chip/ErrorChip.vue: -------------------------------------------------------------------------------- 1 | 6 | -------------------------------------------------------------------------------- /packages/unlighthouse/config.js: -------------------------------------------------------------------------------- 1 | function defineUnlighthouseConfig(config) { 2 | return config 3 | } 4 | 5 | export { defineUnlighthouseConfig } 6 | -------------------------------------------------------------------------------- /packages/unlighthouse/config.mjs: -------------------------------------------------------------------------------- 1 | function defineUnlighthouseConfig(config) { 2 | return config 3 | } 4 | 5 | export { defineUnlighthouseConfig } 6 | -------------------------------------------------------------------------------- /packages/core/src/router/index.ts: -------------------------------------------------------------------------------- 1 | export * from './api' 2 | export * from './broadcasting' 3 | export * from './mockRouter' 4 | export * from './util' 5 | -------------------------------------------------------------------------------- /packages/client/components/Chip/WarningChip.vue: -------------------------------------------------------------------------------- 1 | 6 | -------------------------------------------------------------------------------- /packages/core/src/discovery/index.ts: -------------------------------------------------------------------------------- 1 | export * from './robotsTxt' 2 | export * from './routeDefinitions' 3 | export * from './routes' 4 | export * from './sitemap' 5 | -------------------------------------------------------------------------------- /packages/client/components/Badge.vue: -------------------------------------------------------------------------------- 1 | 6 | -------------------------------------------------------------------------------- /packages/client/components/Card.vue: -------------------------------------------------------------------------------- 1 | 6 | -------------------------------------------------------------------------------- /packages/unlighthouse/config.cjs: -------------------------------------------------------------------------------- 1 | function defineUnlighthouseConfig(config) { 2 | return config 3 | } 4 | 5 | module.exports = { 6 | defineUnlighthouseConfig, 7 | } 8 | -------------------------------------------------------------------------------- /packages/client/components/Results/ResultsPanel.vue: -------------------------------------------------------------------------------- 1 | 8 | -------------------------------------------------------------------------------- /packages/core/src/types/puppeteer.ts: -------------------------------------------------------------------------------- 1 | import type { Cluster, TaskFunction } from 'puppeteer-cluster' 2 | import type { Page } from 'puppeteer-core' 3 | 4 | export { Cluster, Page, TaskFunction } 5 | -------------------------------------------------------------------------------- /packages/client/components/Loading/LoadingSpinner.vue: -------------------------------------------------------------------------------- 1 | 3 | 4 | 7 | -------------------------------------------------------------------------------- /packages/client/components/Btn/BtnAction.vue: -------------------------------------------------------------------------------- 1 | 8 | -------------------------------------------------------------------------------- /packages/unlighthouse/types.d.ts: -------------------------------------------------------------------------------- 1 | import type { DefineUnlighthouseConfig } from 'unlighthouse/config' 2 | 3 | export * from './dist/index' 4 | 5 | declare global { 6 | const defineUnlighthouseConfig: DefineUnlighthouseConfig 7 | } 8 | -------------------------------------------------------------------------------- /packages/client/logic/index.ts: -------------------------------------------------------------------------------- 1 | export * from './actions/rescanSite' 2 | export * from './dark' 3 | export * from './fetch' 4 | export * from './search' 5 | export * from './state' 6 | export * from './static' 7 | export * from './util' 8 | -------------------------------------------------------------------------------- /packages/client/logic/fetch.ts: -------------------------------------------------------------------------------- 1 | import { createFetch } from '@vueuse/core' 2 | import { apiUrl } from './static' 3 | 4 | export function useFetch(url: string) { 5 | const fetch = createFetch({ baseUrl: apiUrl }) 6 | return fetch(url) 7 | } 8 | -------------------------------------------------------------------------------- /packages/core/src/index.ts: -------------------------------------------------------------------------------- 1 | import { fetchUrlRaw, normaliseHost, ReportArtifacts } from './util' 2 | 3 | export * from './build' 4 | export * from './types' 5 | export * from './unlighthouse' 6 | export { fetchUrlRaw, normaliseHost, ReportArtifacts } 7 | -------------------------------------------------------------------------------- /packages/server/build.config.ts: -------------------------------------------------------------------------------- 1 | import { defineBuildConfig } from 'obuild/config' 2 | 3 | export default defineBuildConfig({ 4 | entries: [ 5 | { 6 | type: 'bundle', 7 | input: ['./src/index.ts'], 8 | }, 9 | ], 10 | }) 11 | -------------------------------------------------------------------------------- /packages/unlighthouse/build.config.ts: -------------------------------------------------------------------------------- 1 | import { defineBuildConfig } from 'obuild/config' 2 | 3 | export default defineBuildConfig({ 4 | entries: [ 5 | { 6 | type: 'bundle', 7 | input: ['./src/index.ts'], 8 | }, 9 | ], 10 | }) 11 | -------------------------------------------------------------------------------- /packages/cli/build.config.ts: -------------------------------------------------------------------------------- 1 | import { defineBuildConfig } from 'obuild/config' 2 | 3 | export default defineBuildConfig({ 4 | entries: [ 5 | { 6 | type: 'bundle', 7 | input: ['./src/ci.ts', './src/cli.ts'], 8 | }, 9 | ], 10 | }) 11 | -------------------------------------------------------------------------------- /packages/client/components/Btn/BtnBasic.vue: -------------------------------------------------------------------------------- 1 | 10 | -------------------------------------------------------------------------------- /packages/unlighthouse-ci/build.config.ts: -------------------------------------------------------------------------------- 1 | import { defineBuildConfig } from 'obuild/config' 2 | 3 | export default defineBuildConfig({ 4 | entries: [ 5 | { 6 | type: 'bundle', 7 | input: ['./src/index.ts'], 8 | }, 9 | ], 10 | }) 11 | -------------------------------------------------------------------------------- /packages/unlighthouse/types.d.mts: -------------------------------------------------------------------------------- 1 | export * from './dist/index.js' 2 | 3 | declare global { 4 | import type { UserConfig } from '@unlighthouse/core' 5 | 6 | const defineUnlighthouseConfig: UserConfig | (() => UserConfig) | (() => Promise) 7 | } 8 | -------------------------------------------------------------------------------- /packages/core/build.config.ts: -------------------------------------------------------------------------------- 1 | import { defineBuildConfig } from 'obuild/config' 2 | 3 | export default defineBuildConfig({ 4 | entries: [ 5 | { 6 | type: 'bundle', 7 | input: ['./src/index.ts', './src/lighthouse.ts'], 8 | }, 9 | ], 10 | }) 11 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | # editorconfig.org 2 | root = true 3 | 4 | [*] 5 | indent_size = 2 6 | indent_style = space 7 | end_of_line = lf 8 | charset = utf-8 9 | trim_trailing_whitespace = true 10 | insert_final_newline = true 11 | 12 | [*.md] 13 | trim_trailing_whitespace = false 14 | -------------------------------------------------------------------------------- /packages/core/vendor.d.ts: -------------------------------------------------------------------------------- 1 | declare module 'launch-editor' { 2 | function launch(file: string): Promise 3 | export default launch 4 | } 5 | 6 | declare module 'lighthouse/lighthouse-core/lib/median-run.js' { 7 | export function computeMedianRun(reports: any[]): string 8 | } 9 | -------------------------------------------------------------------------------- /packages/client/main.ts: -------------------------------------------------------------------------------- 1 | import ui from '@nuxt/ui/vue-plugin' 2 | // register vue composition api globally 3 | import { createApp } from 'vue' 4 | import App from './App.vue' 5 | 6 | // tailwind css 7 | import './index.css' 8 | 9 | const app = createApp(App) 10 | app.mount('#app') 11 | app.use(ui) 12 | -------------------------------------------------------------------------------- /crux-api/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "private": true, 3 | "scripts": { 4 | "build": "nitro build", 5 | "dev": "nitro dev", 6 | "prepare": "nitro prepare", 7 | "preview": "node .output/server/index.mjs" 8 | }, 9 | "devDependencies": { 10 | "nitropack": "catalog:crux-api" 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /test/fixtures/react-beta.config.ts: -------------------------------------------------------------------------------- 1 | export default { 2 | site: 'beta.reactjs.org', 3 | debug: true, 4 | scanner: { 5 | device: 'mobile', 6 | throttle: true, 7 | samples: 3, 8 | customSampling: { 9 | '/blog/(.*?)': { 10 | name: 'guide', 11 | }, 12 | }, 13 | }, 14 | } 15 | -------------------------------------------------------------------------------- /crux-api/nitro.config.ts: -------------------------------------------------------------------------------- 1 | // https://nitro.unjs.io/config 2 | export default defineNitroConfig({ 3 | srcDir: 'server', 4 | runtimeConfig: { 5 | google: { 6 | cruxApiToken: '', // .env NITRO_GOOGLE_CRUX_API_TOKEN 7 | }, 8 | }, 9 | routeRules: { 10 | '/api/**': { cors: true }, 11 | }, 12 | compatibilityDate: '2025-06-15', 13 | }) 14 | -------------------------------------------------------------------------------- /packages/unlighthouse/config.d.mts: -------------------------------------------------------------------------------- 1 | import type { UserConfig } from '@unlighthouse/core' 2 | import type { ConfigLayerMeta, DefineConfig } from 'c12' 3 | 4 | export { UserConfig } from 'nuxt/schema' 5 | 6 | export interface DefineUnlighthouseConfig extends DefineConfig {} 7 | export declare const defineUnlighthouseConfig: DefineUnlighthouseConfig 8 | -------------------------------------------------------------------------------- /test/types.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, it } from 'vitest' 2 | import { defineConfig } from '../packages/core/src' 3 | 4 | describe('types', () => { 5 | it('cache on', async () => { 6 | defineConfig({ 7 | site: 'https://unlighthouse.dev', 8 | ci: { 9 | budget: { 10 | seo: 60, 11 | } 12 | } 13 | }) 14 | }) 15 | }) 16 | -------------------------------------------------------------------------------- /test/fixtures/harlanzw-json-expanded.config.ts: -------------------------------------------------------------------------------- 1 | export default { 2 | site: 'https://harlanzw.com', 3 | cache: false, 4 | scanner: { 5 | device: 'desktop', 6 | throttle: false, 7 | }, 8 | ci: { 9 | budget: { 10 | 'best-practices': 50, 11 | 'seo': 50, 12 | 'accessibility': 50, 13 | }, 14 | reporter: 'jsonExpanded', 15 | }, 16 | debug: true, 17 | } 18 | -------------------------------------------------------------------------------- /eslint.config.js: -------------------------------------------------------------------------------- 1 | import antfu from '@antfu/eslint-config' 2 | 3 | export default antfu({ 4 | rules: { 5 | 'no-use-before-define': 'off', 6 | 'node/prefer-global/process': 'off', 7 | 'ts/no-use-before-define': 'off', 8 | 'ts/prefer-ts-expect-error': 'off', 9 | }, 10 | // exclude examples dir 11 | ignores: [ 12 | 'test/*', 13 | 'examples/*', 14 | 'examples/**/*.*', 15 | ], 16 | }) 17 | -------------------------------------------------------------------------------- /packages/client/constants.ts: -------------------------------------------------------------------------------- 1 | // Performance constants to avoid recalculation 2 | export const EXCLUDED_CATEGORIES = ['Overview', 'CrUX'] as const 3 | 4 | export const GAUGE_CONSTANTS = { 5 | RADIUS: 56, 6 | STROKE_WIDTH: 8, 7 | get CIRCUMFERENCE() { 8 | return 2 * Math.PI * this.RADIUS 9 | }, 10 | get ROTATION_OFFSET() { 11 | return 0.25 * this.STROKE_WIDTH / this.CIRCUMFERENCE 12 | }, 13 | } as const 14 | -------------------------------------------------------------------------------- /packages/client/components/Audit/AuditResultItemsLength.vue: -------------------------------------------------------------------------------- 1 | 13 | 14 | 19 | -------------------------------------------------------------------------------- /test/fixtures/harlanzw.config.ts: -------------------------------------------------------------------------------- 1 | export default { 2 | site: 'https://harlanzw.com', 3 | cache: false, 4 | scanner: { 5 | device: 'desktop', 6 | throttle: false, 7 | }, 8 | ci: { 9 | budget: { 10 | 'best-practices': 50, 11 | 'seo': 50, 12 | 'accessibility': 50, 13 | }, 14 | }, 15 | lighthouseOptions: { 16 | onlyCategories: ['best-practices', 'seo', 'accessibility'], 17 | }, 18 | debug: true, 19 | } 20 | -------------------------------------------------------------------------------- /packages/client/components/Cell/CellTapTargets.vue: -------------------------------------------------------------------------------- 1 | 10 | 11 | 14 | -------------------------------------------------------------------------------- /vitest.config.ts: -------------------------------------------------------------------------------- 1 | import type { AliasOptions } from 'vite' 2 | import { resolve } from 'node:path' 3 | import { defineConfig } from 'vite' 4 | 5 | const r = (p: string) => resolve(__dirname, p) 6 | 7 | export const alias: AliasOptions = { 8 | 'unlighthouse': r('./packages/core/src/'), 9 | '@unlighthouse/client': r('./packages/client/src/'), 10 | } 11 | 12 | export default defineConfig({ 13 | test: { 14 | testTimeout: 3000000, 15 | }, 16 | resolve: { 17 | alias, 18 | }, 19 | }) 20 | -------------------------------------------------------------------------------- /packages/client/types.d.ts: -------------------------------------------------------------------------------- 1 | import type { ClientOptionsPayload, ScanMeta, UnlighthouseRouteReport } from '@unlighthouse/core' 2 | 3 | declare global { 4 | interface Window { 5 | /** 6 | * Are we running the app in a demo / offline mode. 7 | */ 8 | __unlighthouse_static?: boolean 9 | /** 10 | * Data provided for offline / demo mode. 11 | */ 12 | __unlighthouse_payload: { options: ClientOptionsPayload, scanMeta: ScanMeta, reports: UnlighthouseRouteReport[] } 13 | } 14 | 15 | const __UNLIGHTHOUSE_VERSION__: string 16 | } 17 | -------------------------------------------------------------------------------- /packages/cli/src/errors.ts: -------------------------------------------------------------------------------- 1 | import consola from 'consola' 2 | 3 | export class PrettyError extends Error { 4 | constructor(message: string) { 5 | super(message) 6 | this.name = this.constructor.name 7 | 8 | if (typeof Error.captureStackTrace === 'function') 9 | Error.captureStackTrace(this, this.constructor) 10 | 11 | else 12 | this.stack = new Error(message).stack 13 | } 14 | } 15 | 16 | export function handleError(error: unknown) { 17 | if (error instanceof PrettyError) 18 | consola.error(error.message) 19 | else 20 | consola.error(error) 21 | process.exit(1) 22 | } 23 | -------------------------------------------------------------------------------- /packages/client/logic/actions/rescanSite.ts: -------------------------------------------------------------------------------- 1 | import type { UseFetchReturn } from '@vueuse/core' 2 | import type { Ref } from 'vue' 3 | import { useFetch } from '../fetch' 4 | 5 | export const rescanSiteRequest: Ref | null> = ref(null) 6 | 7 | export function rescanSite(done: () => void) { 8 | const fetch = useFetch>('/reports/rescan').post() 9 | rescanSiteRequest.value = fetch 10 | fetch.onFetchResponse(() => { 11 | done() 12 | }) 13 | } 14 | 15 | export const isRescanSiteRequestRunning = computed(() => { 16 | return rescanSiteRequest.value?.isFetching 17 | }) 18 | -------------------------------------------------------------------------------- /packages/client/components/StatusChip.vue: -------------------------------------------------------------------------------- 1 | 14 | 15 | 26 | -------------------------------------------------------------------------------- /packages/client/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../tsconfig.json", 3 | "compilerOptions": { 4 | "baseUrl": ".", 5 | "paths": { 6 | "@/*": ["./src/*"], 7 | "~/*": ["./src/*"], 8 | "#build/ui": [ 9 | "./node_modules/@nuxt/ui/.nuxt/ui" 10 | ] 11 | }, 12 | "types": [ 13 | "vite/client", 14 | "unplugin-vue-components/vite" 15 | ] 16 | }, 17 | "include": [ 18 | "*.ts", 19 | "*.vue", 20 | "**/*.ts", 21 | "**/*.vue", 22 | "**/*.d.ts", 23 | "auto-imports.d.ts", 24 | "components.d.ts" 25 | ], 26 | "exclude": [ 27 | "dist", 28 | "node_modules" 29 | ] 30 | } 31 | -------------------------------------------------------------------------------- /packages/client/components/Results/ResultsRow.vue: -------------------------------------------------------------------------------- 1 | 11 | 12 | 23 | -------------------------------------------------------------------------------- /packages/client/components/Results/ResultsRoute.vue: -------------------------------------------------------------------------------- 1 | 9 | 10 | 22 | -------------------------------------------------------------------------------- /packages/cli/src/reporters/jsonSimple.ts: -------------------------------------------------------------------------------- 1 | import type { UnlighthouseRouteReport } from '../types' 2 | import type { ReportJsonSimple, SimpleRouteReport } from './types' 3 | 4 | export function reportJsonSimple(reports: UnlighthouseRouteReport[]): ReportJsonSimple { 5 | return reports 6 | .map((report) => { 7 | const scores: Record = {} 8 | Object.values(report.report.categories).forEach((category) => { 9 | // @ts-expect-error untyped 10 | scores[category.key] = category.score 11 | }) 12 | return { 13 | path: report.route.path, 14 | score: report.report?.score, 15 | ...scores, 16 | } 17 | }) 18 | } 19 | -------------------------------------------------------------------------------- /packages/core/src/data/scanMeta.ts: -------------------------------------------------------------------------------- 1 | import type { ScanMeta } from '../types' 2 | import { useUnlighthouse } from '../unlighthouse' 3 | 4 | export function createScanMeta(): ScanMeta { 5 | const { worker } = useUnlighthouse() 6 | 7 | const data = worker.reports().filter(r => r.tasks.inspectHtmlTask === 'completed') 8 | const reportsWithScore = data.filter(r => r.report?.score) as { report: { score: number } }[] 9 | const score = (reportsWithScore 10 | .map(r => r.report.score) 11 | .reduce((s, a) => s + a, 0) / reportsWithScore.length) || 0 12 | 13 | return { 14 | favicon: data?.[0]?.seo?.favicon, 15 | monitor: worker.monitor(), 16 | routes: data.length || 0, 17 | score, 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /packages/client/components/Cell/CellIndexable.vue: -------------------------------------------------------------------------------- 1 | 10 | 11 | 18 | -------------------------------------------------------------------------------- /packages/client/logic/dark.ts: -------------------------------------------------------------------------------- 1 | import { useStorage, useToggle } from '@vueuse/core' 2 | 3 | export const mode = useStorage('vueuse-color-scheme', 'dark') 4 | export const isDark = computed({ 5 | get() { 6 | return mode.value === 'dark' 7 | }, 8 | set(v) { 9 | mode.value = v ? 'dark' : 'light' 10 | }, 11 | }) 12 | 13 | watch(isDark, () => { 14 | const el = window?.document.querySelector('html') 15 | if (!el) 16 | return 17 | if (isDark.value) { 18 | el.classList.add('dark') 19 | el.classList.remove('light') 20 | } 21 | else { 22 | el.classList.add('light') 23 | el.classList.remove('dark') 24 | } 25 | }, { flush: 'post', immediate: true }) 26 | 27 | export const toggleDark = useToggle(isDark) 28 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: Test 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | 8 | pull_request: 9 | branches: 10 | - main 11 | 12 | jobs: 13 | build: 14 | runs-on: ubuntu-latest 15 | steps: 16 | - uses: actions/checkout@v5 17 | 18 | - name: Install pnpm 19 | uses: pnpm/action-setup@v4.0.0 20 | 21 | - name: Use Node.js v24 22 | uses: actions/setup-node@v5 23 | with: 24 | node-version: v24 25 | registry-url: https://registry.npmjs.org/ 26 | cache: pnpm 27 | 28 | - run: pnpm install && pnpm add puppeteer 29 | 30 | - name: Build 31 | run: pnpm run build 32 | 33 | - name: Test 34 | run: pnpm run test 35 | -------------------------------------------------------------------------------- /crux-api/server/api/[domain]/crux/history.get.ts: -------------------------------------------------------------------------------- 1 | import { createError, defineCachedEventHandler } from '#imports' 2 | import { getRouterParam } from 'h3' 3 | import { fetchCrux } from '../../../app/services/crux' 4 | 5 | export default defineCachedEventHandler(async (event) => { 6 | const domain = getRouterParam(event, 'domain', { decode: true }) 7 | if (!domain) { 8 | throw createError({ 9 | statusCode: 404, 10 | statusMessage: 'Site not found', 11 | }) 12 | } 13 | return fetchCrux(domain) 14 | }, { 15 | base: 'crux2', 16 | swr: true, 17 | shouldBypassCache: () => true, // !!import.meta.dev, 18 | getKey: event => getRouterParam(event, 'domain', { decode: true }), 19 | maxAge: 60 * 60, 20 | staleMaxAge: 24 * 60 * 60, 21 | }) 22 | -------------------------------------------------------------------------------- /packages/server/src/index.ts: -------------------------------------------------------------------------------- 1 | import { useUnlighthouse } from '@unlighthouse/core' 2 | import { createApp, toNodeListener } from 'h3' 3 | import { listen } from 'listhen' 4 | 5 | /** 6 | * Create a web server and web app to host the unlighthouse client and API on. 7 | * 8 | * Some providers, such as Nuxt, do not need this, so this can be safely tree-shaken. 9 | */ 10 | export async function createServer(): Promise<{ app: any, server: any }> { 11 | const { resolvedConfig } = useUnlighthouse() 12 | 13 | const app = createApp() 14 | const server = await listen(toNodeListener(app), { 15 | // @ts-expect-error untyped 16 | ...resolvedConfig.server, 17 | // delay opening the server until the app is ready 18 | open: false, 19 | }) 20 | return { app, server } 21 | } 22 | -------------------------------------------------------------------------------- /packages/client/components/Btn/BtnTab.vue: -------------------------------------------------------------------------------- 1 | 6 | 7 | 21 | -------------------------------------------------------------------------------- /test/fixtures/staging-vue.config.ts: -------------------------------------------------------------------------------- 1 | export default { 2 | site: 'https://vuejs.org/', 3 | debug: true, 4 | hooks: { 5 | 'puppeteer:before-goto': async (page) => { 6 | const deleteSelector = '.VPNav' 7 | page.waitForNavigation().then(async () => { 8 | await page.waitForTimeout(1000) 9 | await page.evaluate((sel) => { 10 | const elements = document.querySelectorAll(sel) 11 | for (let i = 0; i < elements.length; i++) 12 | elements[i].parentNode.removeChild(elements[i]) 13 | }, deleteSelector) 14 | }) 15 | }, 16 | }, 17 | scanner: { 18 | device: 'mobile', 19 | throttle: true, 20 | samples: 3, 21 | customSampling: { 22 | '/guide/(.*?)': { 23 | name: 'guide', 24 | }, 25 | }, 26 | }, 27 | } 28 | -------------------------------------------------------------------------------- /packages/server/README.md: -------------------------------------------------------------------------------- 1 | # @unlighthouse/server 2 | 3 | The server package for [Unlighthouse](https://github.com/harlan-zw/unlighthouse) that provides the web server and API endpoints for the scanning interface. 4 | 5 | ## Usage 6 | 7 | ```ts 8 | import { createServer } from '@unlighthouse/server' 9 | 10 | const { server, app } = await createServer() 11 | // server is an instance of listhen, app is an instance of h3 12 | await unlighthouse.setServerContext({ url: server.url, server: server.server, app }) 13 | await unlighthouse.start() 14 | ``` 15 | 16 | ## Documentation 17 | 18 | - [API Reference](https://unlighthouse.dev/api/index.html) 19 | - [Configuration Guide](https://unlighthouse.dev/guide/config.html) 20 | 21 | ## License 22 | 23 | MIT License © 2021-PRESENT [Harlan Wilton](https://github.com/harlan-zw) 24 | -------------------------------------------------------------------------------- /packages/client/components/SearchBox.vue: -------------------------------------------------------------------------------- 1 | 4 | 5 | 18 | -------------------------------------------------------------------------------- /packages/core/test/filters.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, expect, it } from 'vitest' 2 | import { isImplicitOrExplicitHtml } from '../src/util/filter' 3 | 4 | describe('filters', () => { 5 | it ('misc file paths', () => { 6 | expect(isImplicitOrExplicitHtml('')).toBe(true) 7 | expect(isImplicitOrExplicitHtml('/')).toBe(true) 8 | expect(isImplicitOrExplicitHtml('/some.foo/test')).toBe(true) 9 | expect(isImplicitOrExplicitHtml('/some/file.pdf/')).toBe(true) 10 | expect(isImplicitOrExplicitHtml('/dist/assets/chunk[213.4.931294]')).toBe(true) 11 | 12 | // file paths 13 | expect(isImplicitOrExplicitHtml('/foo/bar.fr9f9')).toBe(false) 14 | expect(isImplicitOrExplicitHtml('/some/file.pdf')).toBe(false) 15 | expect(isImplicitOrExplicitHtml('/dist/assets/chunk[213.4.931294].css')).toBe(false) 16 | }) 17 | }) 18 | -------------------------------------------------------------------------------- /packages/client/components/Btn/BtnIcon.vue: -------------------------------------------------------------------------------- 1 | 11 | 12 | 33 | -------------------------------------------------------------------------------- /packages/core/src/puppeteer/cluster.ts: -------------------------------------------------------------------------------- 1 | import type { UnlighthousePuppeteerCluster } from '../types' 2 | import { Cluster } from 'puppeteer-cluster' 3 | import { useUnlighthouse } from '../unlighthouse' 4 | 5 | /** 6 | * Create an instance of puppeteer-cluster 7 | */ 8 | export async function launchPuppeteerCluster(): Promise { 9 | const { resolvedConfig } = useUnlighthouse() 10 | // @ts-expect-error untyped 11 | const cluster = await Cluster.launch({ 12 | puppeteerOptions: resolvedConfig.puppeteerOptions, 13 | ...resolvedConfig.puppeteerClusterOptions, 14 | }) as unknown as UnlighthousePuppeteerCluster 15 | // hacky solution to mock the display which avoids spamming the console while still monitoring system stats 16 | cluster.display = { 17 | log() {}, 18 | resetCursor() {}, 19 | } 20 | return cluster 21 | } 22 | -------------------------------------------------------------------------------- /packages/client/components/Card/CardPackages.vue: -------------------------------------------------------------------------------- 1 | 12 | 13 | 29 | -------------------------------------------------------------------------------- /packages/client/components/Card/CardRouteScanProgress.vue: -------------------------------------------------------------------------------- 1 | 12 | 13 | 35 | -------------------------------------------------------------------------------- /packages/client/README.md: -------------------------------------------------------------------------------- 1 | # @unlighthouse/client 2 | 3 | The Vue-based user interface for [Unlighthouse](https://github.com/harlan-zw/unlighthouse), providing an interactive dashboard to view and analyze Lighthouse scan results. 4 | 5 | ## Features 6 | 7 | - Interactive dashboard with performance metrics 8 | - Real-time scan progress tracking 9 | - Detailed Lighthouse reports per page 10 | - Performance charts and visualizations 11 | - Export capabilities 12 | 13 | ## Development 14 | 15 | ```bash 16 | # Install dependencies 17 | pnpm install 18 | 19 | # Start development server 20 | pnpm dev 21 | 22 | # Build for production 23 | pnpm build 24 | ``` 25 | 26 | ## Documentation 27 | 28 | - [Integration Guide](https://unlighthouse.dev/integrations/) 29 | - [API Reference](https://unlighthouse.dev/api/index.html) 30 | 31 | ## License 32 | 33 | MIT License © 2021-PRESENT [Harlan Wilton](https://github.com/harlan-zw) 34 | -------------------------------------------------------------------------------- /packages/core/src/logger.ts: -------------------------------------------------------------------------------- 1 | import type { ConsolaInstance } from 'consola' 2 | import { createConsola } from 'consola' 3 | import { createContext } from 'unctx' 4 | import { AppName } from './constants' 5 | 6 | const loggerCtx = createContext() 7 | 8 | export function createLogger(debug = false) { 9 | const logger = createConsola().withTag(AppName) 10 | 11 | if (debug) { 12 | // debug 13 | logger.level = 4 14 | } 15 | loggerCtx.set(logger) 16 | return logger 17 | } 18 | 19 | /** 20 | * Gets the instantiated logger instance using the shared context, persists the application logging configuration. 21 | */ 22 | export const useLogger: () => ConsolaInstance = () => { 23 | let logger = loggerCtx.use() 24 | // just in-case the logger wasn't initialised, we want to always return an instance to avoid null checks in DX 25 | if (!logger) 26 | logger = createLogger() 27 | 28 | return logger 29 | } 30 | -------------------------------------------------------------------------------- /packages/client/components/Cell/CellScreenshotThumbnails.vue: -------------------------------------------------------------------------------- 1 | 17 | 18 | 25 | -------------------------------------------------------------------------------- /packages/client/logic/util.ts: -------------------------------------------------------------------------------- 1 | export function extractBgColor(str: string) { 2 | const regex = /background color: (.*?),/ 3 | const m = regex.exec(str) 4 | 5 | if (m !== null) { 6 | // The result can be accessed through the `m`-variable. 7 | return m[1] 8 | } 9 | } 10 | 11 | export function extractFgColor(str: string) { 12 | const regex = /foreground color: (.*?),/ 13 | const m = regex.exec(str) 14 | 15 | if (m !== null) { 16 | // The result can be accessed through the `m`-variable. 17 | return m[1] 18 | } 19 | } 20 | 21 | export function formatBytes(bytes: number, decimals = 2) { 22 | if (bytes === 0) 23 | return '0B' 24 | 25 | const k = 1024 26 | const dm = decimals < 0 ? 0 : decimals 27 | const sizes = ['B', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB'] 28 | 29 | const i = Math.floor(Math.log(bytes) / Math.log(k)) 30 | 31 | return `${Number.parseFloat((bytes / k ** i).toFixed(dm))} ${sizes[i]}` 32 | } 33 | -------------------------------------------------------------------------------- /docs/1.guide/recipes/spa.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: "Single-Page Applications" 3 | description: "Configure Unlighthouse to properly scan single-page applications (SPAs) with client-side routing." 4 | navigation: 5 | title: "SPAs" 6 | --- 7 | 8 | ## Introduction 9 | 10 | By default, Unlighthouse assumes server-side rendered (SSR) pages where links are discoverable in the initial HTML. Single-page applications require JavaScript execution for proper link discovery and content rendering. 11 | 12 | ## Enable JavaScript Execution 13 | 14 | Allow Puppeteer to execute JavaScript before extracting page content: 15 | 16 | ```ts 17 | import { defineUnlighthouseConfig } from 'unlighthouse/config' 18 | 19 | export default defineUnlighthouseConfig({ 20 | scanner: { 21 | skipJavascript: false, // Enable JS execution for SPAs 22 | }, 23 | }) 24 | ``` 25 | 26 | ::note 27 | Enabling JavaScript execution increases scan time but is necessary for accurate SPA scanning. 28 | :: 29 | -------------------------------------------------------------------------------- /packages/core/src/router/mockVueRouter.ts: -------------------------------------------------------------------------------- 1 | // @todo move this to integrations which need it 2 | // import type { RouteRecordRaw } from 'vue-router' 3 | // import { createMemoryHistory, createRouter } from 'vue-router' 4 | // import type { MockRouter, RouteDefinition } from '../types' 5 | 6 | /** 7 | * A mocker router using vue-router as the implementation. 8 | * 9 | * Needed for Vite and next.js. 10 | * 11 | * @param routeDefinitions 12 | */ 13 | /* export const createMockVueRouter: (routeDefinitions: RouteDefinition[]) => MockRouter 14 | = (routeDefinitions) => { 15 | const router = createRouter({ 16 | history: createMemoryHistory(), 17 | routes: routeDefinitions as RouteRecordRaw[], 18 | }) 19 | 20 | return { 21 | match(path: string) { 22 | const { name } = router.resolve(path) as RouteDefinition 23 | return routeDefinitions.filter(d => d.name === name)[0] 24 | }, 25 | } 26 | } 27 | */ 28 | -------------------------------------------------------------------------------- /packages/client/components/StatItem.vue: -------------------------------------------------------------------------------- 1 | 13 | 14 | 38 | -------------------------------------------------------------------------------- /packages/client/components/Cell/CellColorContrast.vue: -------------------------------------------------------------------------------- 1 | 11 | 12 | 32 | -------------------------------------------------------------------------------- /packages/client/logic/formatting.ts: -------------------------------------------------------------------------------- 1 | export function useHumanMs(ms: number): string { 2 | // need to convert it such < 1000 we say $x ms, otherwise we say $x s 3 | if (ms < 1000) 4 | return `${ms}ms` 5 | return `${(ms / 1000).toFixed(1)}s` 6 | } 7 | 8 | function useHumanFriendlyNumber(number: Ref, decimals?: number): ComputedRef 9 | function useHumanFriendlyNumber(number: number, decimals?: number): string 10 | export function useHumanFriendlyNumber(number: MaybeRef, decimals?: number) { 11 | const format = (number: number) => { 12 | // apply decimals if defined 13 | if (typeof decimals !== 'undefined') 14 | number = Number.parseFloat(number.toFixed(decimals)) 15 | return new Intl.NumberFormat('en', { notation: 'compact' }).format(number) 16 | } 17 | if (isRef(number)) { 18 | return computed(() => { 19 | return format(number.value) 20 | }) 21 | } 22 | // use intl to format the number, should have `k` or `m` suffix if needed 23 | return format(number) 24 | } 25 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release 2 | 3 | permissions: 4 | contents: write 5 | id-token: write 6 | 7 | on: 8 | push: 9 | tags: 10 | - 'v*' 11 | 12 | jobs: 13 | release: 14 | runs-on: ubuntu-latest 15 | steps: 16 | - uses: actions/checkout@v5 17 | with: 18 | fetch-depth: 0 19 | 20 | - name: Install pnpm 21 | uses: pnpm/action-setup@v4 22 | 23 | - name: Set node 24 | uses: actions/setup-node@v5 25 | with: 26 | node-version: latest 27 | cache: pnpm 28 | registry-url: 'https://registry.npmjs.org' 29 | 30 | - name: Force Set pnpm Registry 31 | run: pnpm config set registry https://registry.npmjs.org 32 | 33 | - run: npx changelogithub 34 | env: 35 | GITHUB_TOKEN: ${{secrets.GITHUB_TOKEN}} 36 | 37 | - name: Install Dependencies 38 | run: pnpm i 39 | 40 | - name: Build 41 | run: pnpm build 42 | 43 | - run: pnpm publish -r --access public --no-git-checks 44 | -------------------------------------------------------------------------------- /packages/client/components/Tooltip.vue: -------------------------------------------------------------------------------- 1 | 4 | 5 | 29 | -------------------------------------------------------------------------------- /packages/client/components/ModalTrigger.vue: -------------------------------------------------------------------------------- 1 | 21 | 22 | 39 | -------------------------------------------------------------------------------- /packages/client/components/Card/CardModuleSizes.vue: -------------------------------------------------------------------------------- 1 | 12 | 13 | 38 | -------------------------------------------------------------------------------- /packages/client/components/Cell/CellImageIssues.vue: -------------------------------------------------------------------------------- 1 | 30 | 31 | 36 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2016-present - Harlan Wilton 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /packages/core/src/puppeteer/tasks/userFlow.ts: -------------------------------------------------------------------------------- 1 | // @todo Implement. Currently this code does not work, presumably a conflict with puppeteer-cluster 2 | 3 | /* import { startFlow } from 'lighthouse/lighthouse-core/fraggle-rock/api.js' 4 | import fs from 'fs-extra' 5 | import type { PuppeteerTask } from '../../types' 6 | 7 | export const userFlowTask: PuppeteerTask = async(props) => { 8 | const { page, data: routeReport } = props 9 | 10 | const newPage = await page.browser().newPage() 11 | // Get a session handle to be able to send protocol commands to the page. 12 | const flow = await startFlow(newPage, { name: 'Cold and warm navigations' }) 13 | await flow.navigate(routeReport.route.url, { 14 | stepName: 'Cold navigation', 15 | }) 16 | await flow.navigate(routeReport.route.url, { 17 | stepName: 'Warm navigation', 18 | configContext: { 19 | settingsOverrides: { disableStorageReset: true }, 20 | }, 21 | }) 22 | 23 | await page.browser().close() 24 | 25 | const report = flow.generateReport() 26 | 27 | fs.writeFileSync(routeReport.htmlPayload.replace('lighthouse.html', 'flow.report.html'), report) 28 | 29 | return routeReport 30 | } */ 31 | -------------------------------------------------------------------------------- /docs/1.guide/recipes/client.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: "Customizing the UI" 3 | description: "Modify Unlighthouse client interface columns and display to show custom metrics and data." 4 | navigation: 5 | title: "UI Customization" 6 | --- 7 | 8 | ## Introduction 9 | 10 | Unlighthouse's client interface can be customized to display the metrics most relevant to your needs. Modify columns, add custom data, and tailor the UI to your workflow. 11 | 12 | ## Customizing Columns 13 | 14 | Replace or add columns to display specific Lighthouse metrics: 15 | 16 | ### Example: Replace FCP with Server Response Time 17 | 18 | ```ts 19 | import { defineUnlighthouseConfig } from 'unlighthouse/config' 20 | 21 | export default defineUnlighthouseConfig({ 22 | hooks: { 23 | 'resolved-config': function (config) { 24 | config.client.columns.performance[2] = { 25 | cols: 1, 26 | label: 'Response Time', 27 | tooltip: 'Time for the server to respond', 28 | sortKey: 'numericValue', 29 | key: 'report.audits.server-response-time', 30 | } 31 | }, 32 | }, 33 | }) 34 | ``` 35 | 36 | ::tip 37 | See the [Column API Reference](/api/glossary/#columns) for all available column options. 38 | :: 39 | -------------------------------------------------------------------------------- /packages/cli/src/types.ts: -------------------------------------------------------------------------------- 1 | import type { UnlighthouseRouteReport, ValidReportTypes } from '@unlighthouse/core' 2 | 3 | export interface CliOptions { 4 | host?: string 5 | help?: boolean 6 | urls?: string 7 | auth?: string 8 | cookies?: string 9 | defaultQueryParams?: string 10 | extraHeaders?: string 11 | excludeUrls?: string 12 | includeUrls?: string 13 | site?: string 14 | routerPrefix?: string 15 | throttle?: boolean 16 | desktop?: boolean 17 | mobile?: boolean 18 | cache?: boolean 19 | noCache?: boolean 20 | version?: boolean 21 | root?: string 22 | configFile?: string 23 | debug?: boolean 24 | samples?: number 25 | enableI18nPages?: boolean 26 | disableI18nPages?: boolean 27 | enableJavascript?: boolean 28 | disableJavascript?: boolean 29 | disableRobotsTxt?: boolean 30 | disableSitemap?: boolean 31 | disableDynamicSampling?: boolean 32 | sitemaps?: string 33 | userAgent?: string 34 | } 35 | 36 | export interface CiOptions extends CliOptions { 37 | budget: number 38 | buildStatic: boolean 39 | reporter?: ValidReportTypes | false 40 | lhciHost?: string 41 | lhciBuildToken?: string 42 | lhciAuth?: string 43 | } 44 | 45 | export { UnlighthouseRouteReport } 46 | -------------------------------------------------------------------------------- /packages/core/src/router/mockRouter.ts: -------------------------------------------------------------------------------- 1 | import type { MockRouter, RouteDefinition } from '../types' 2 | import { parse } from 'regexparam' 3 | import { useLogger } from '../logger' 4 | 5 | /** 6 | * The default mock router using regexparam as the matcher 7 | * 8 | * Used by nuxt and the default route definition discoverer. 9 | * 10 | * @param routeDefinitions 11 | */ 12 | export const createMockRouter: (routeDefinitions: RouteDefinition[]) => MockRouter 13 | = (routeDefinitions: RouteDefinition[]) => { 14 | const logger = useLogger() 15 | const patterns = routeDefinitions 16 | .map((r) => { 17 | try { 18 | return { 19 | routeDefinition: r, 20 | matcher: parse(r.path), 21 | } 22 | } 23 | catch (e) { 24 | logger.debug('Failed to parse path', r.path, e) 25 | } 26 | return false 27 | }) 28 | .filter(r => r !== false) 29 | 30 | return { 31 | match(path: string) { 32 | const matched = patterns.filter(p => p && p.matcher.pattern.test(path)) 33 | if (matched.length > 0 && matched[0]) 34 | return matched[0].routeDefinition 35 | 36 | return false 37 | }, 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /packages/server/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@unlighthouse/server", 3 | "type": "module", 4 | "version": "0.17.4", 5 | "description": "Server for Unlighthouse", 6 | "license": "MIT", 7 | "homepage": "https://github.com/harlan-zw/unlighthouse#readme", 8 | "repository": { 9 | "type": "git", 10 | "url": "git+https://github.com/harlan-zw/unlighthouse.git", 11 | "directory": "packages/server" 12 | }, 13 | "bugs": { 14 | "url": "https://github.com/harlan-zw/unlighthouse/issues" 15 | }, 16 | "keywords": [ 17 | "lighthouse", 18 | "audit", 19 | "seo", 20 | "performance", 21 | "server", 22 | "unlighthouse" 23 | ], 24 | "sideEffects": false, 25 | "exports": { 26 | ".": { 27 | "types": "./dist/index.d.ts", 28 | "import": "./dist/index.mjs" 29 | } 30 | }, 31 | "main": "dist/index.mjs", 32 | "module": "dist/index.mjs", 33 | "types": "dist/index.d.ts", 34 | "files": [ 35 | "*.d.ts", 36 | "dist" 37 | ], 38 | "scripts": { 39 | "build": "obuild", 40 | "stub": "obuild --stub" 41 | }, 42 | "dependencies": { 43 | "@unlighthouse/core": "workspace:*", 44 | "h3": "catalog:dependencies", 45 | "listhen": "catalog:dependencies" 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /docs/1.guide/recipes/improving-accuracy.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: "Improving Accuracy" 3 | description: "Optimize Lighthouse scan accuracy with multiple samples and reduced concurrency for more reliable results." 4 | navigation: 5 | title: "Improving Accuracy" 6 | --- 7 | 8 | ## Introduction 9 | 10 | Lighthouse performance scores can vary between runs due to network conditions, CPU load, and other factors. These techniques help you achieve more consistent and accurate results. 11 | 12 | ## Multiple Samples Per URL 13 | 14 | Run Lighthouse multiple times and average the results for better accuracy: 15 | 16 | ```ts 17 | import { defineUnlighthouseConfig } from 'unlighthouse/config' 18 | 19 | export default defineUnlighthouseConfig({ 20 | scanner: { 21 | samples: 3, // Run 3 scans per URL and average results 22 | }, 23 | }) 24 | ``` 25 | 26 | ## Reduce Parallel Scans 27 | 28 | Limit concurrent workers to reduce CPU contention and improve score consistency: 29 | 30 | ```ts 31 | import { defineUnlighthouseConfig } from 'unlighthouse/config' 32 | 33 | export default defineUnlighthouseConfig({ 34 | puppeteerClusterOptions: { 35 | maxConcurrency: 1, // Single worker for maximum accuracy 36 | }, 37 | }) 38 | ``` 39 | 40 | ::tip 41 | Combine multiple samples with reduced concurrency for the most accurate results, though this will increase scan time. 42 | :: 43 | -------------------------------------------------------------------------------- /packages/client/components/Disclosure/DisclosureHandle.vue: -------------------------------------------------------------------------------- 1 | 3 | 4 | 30 | -------------------------------------------------------------------------------- /packages/client/components/Loading/LoadingStatusIcon.vue: -------------------------------------------------------------------------------- 1 | 43 | 44 | 50 | -------------------------------------------------------------------------------- /packages/client/components/Cell/CellMetaDescription.vue: -------------------------------------------------------------------------------- 1 | 49 | 50 | 60 | -------------------------------------------------------------------------------- /packages/cli/src/reporters/csvSimple.ts: -------------------------------------------------------------------------------- 1 | import type { UnlighthouseRouteReport } from '../types' 2 | 3 | function escapeValueForCsv(value: string | number | boolean): string { 4 | if (typeof value === 'number' || typeof value === 'boolean') 5 | return String(value) 6 | return `"${value.replace(/"/g, '""')}"` 7 | } 8 | 9 | export function csvSimpleFormat(reports: UnlighthouseRouteReport[]): { headers: string[], body: any } { 10 | const headers = ['URL', 'Score'] 11 | Object.values(reports[0].report.categories).forEach((category) => { 12 | headers.push(category.title) 13 | }) 14 | 15 | const body = reports 16 | .map(({ report, route }) => { 17 | const topLevelScoreKeys = [] 18 | Object.keys(report.categories).forEach((category) => { 19 | topLevelScoreKeys.push(Math.round(report.categories[category].score * 100)) 20 | }) 21 | // map to the format 22 | return [ 23 | route.path, 24 | Math.round(report.score * 100), 25 | // list all top level scores (performance, accessibility, etc) 26 | ...topLevelScoreKeys, 27 | ] 28 | .map(escapeValueForCsv) 29 | }) 30 | 31 | return { 32 | headers, 33 | body, 34 | } 35 | } 36 | 37 | export function reportCSVSimple(reports: UnlighthouseRouteReport[]): string { 38 | const { headers, body } = csvSimpleFormat(reports) 39 | return [ 40 | headers.join(','), 41 | ...body.map(row => row.join(',')), 42 | ] 43 | .flat() 44 | .join('\n') 45 | } 46 | -------------------------------------------------------------------------------- /packages/client/components/Cell/CellScoresOverview.vue: -------------------------------------------------------------------------------- 1 | 10 | 11 | 30 | -------------------------------------------------------------------------------- /packages/client/components/AuditResultWithTooltip.vue: -------------------------------------------------------------------------------- 1 | 10 | 11 | 42 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ESNext", 4 | "jsx": "preserve", 5 | "jsxImportSource": "vue", 6 | "lib": [ 7 | "ESNext", 8 | "dom", 9 | "dom.iterable", 10 | "webworker" 11 | ], 12 | "moduleDetection": "force", 13 | "useDefineForClassFields": true, 14 | "module": "es2022", 15 | "moduleResolution": "Bundler", 16 | "paths": { 17 | "unlighthouse": ["./packages/unlighthouse/src"], 18 | "@unlighthouse/client": ["./packages/client/src"], 19 | "@unlighthouse/cli": ["./packages/cli/src"], 20 | "@unlighthouse/core": ["./packages/core/src"], 21 | "@unlighthouse/server": ["./packages/server/src"], 22 | "@unlighthouse/nuxt": ["./integrations/nuxt/src"], 23 | "@unlighthouse/vite": ["./integrations/vite/src"], 24 | "@unlighthouse/webpack": ["./integrations/webpack/src"] 25 | }, 26 | "resolveJsonModule": true, 27 | "types": [], 28 | "allowJs": true, 29 | "maxNodeModuleJsDepth": 200, 30 | "strict": true, 31 | "noImplicitOverride": true, 32 | "noImplicitThis": true, 33 | "noUncheckedIndexedAccess": false, 34 | "noEmit": true, 35 | "allowSyntheticDefaultImports": true, 36 | "esModuleInterop": true, 37 | "forceConsistentCasingInFileNames": true, 38 | "isolatedModules": true, 39 | "verbatimModuleSyntax": true, 40 | "skipLibCheck": true 41 | }, 42 | "include": [ 43 | "**/*.d.ts" 44 | ], 45 | "exclude": [ 46 | "**/dist/**", 47 | "**/.unlighthouse/**", 48 | "**/node_modules/**" 49 | ] 50 | } 51 | -------------------------------------------------------------------------------- /.github/workflows/demo.yml: -------------------------------------------------------------------------------- 1 | name: Build Unlighthouse Demo 2 | 3 | on: 4 | workflow_dispatch: 5 | push: 6 | tags: 7 | - 'v*' 8 | 9 | jobs: 10 | demo: 11 | runs-on: ubuntu-latest 12 | steps: 13 | - uses: actions/checkout@v5 14 | with: 15 | fetch-depth: 0 16 | 17 | - name: Install pnpm 18 | uses: pnpm/action-setup@v4 19 | 20 | - name: Use Node.js v24 21 | uses: actions/setup-node@v5 22 | with: 23 | node-version: v24 24 | registry-url: https://registry.npmjs.org/ 25 | cache: pnpm 26 | 27 | - name: Install Dependencies 28 | run: pnpm install && pnpm add puppeteer 29 | 30 | - name: PNPM build 31 | run: pnpm run build 32 | 33 | - name: Run Unlighthouse CI 34 | run: pnpm ci:docs && mv .unlighthouse dist 35 | env: 36 | NODE_AUTH_TOKEN: ${{secrets.NPM_TOKEN}} 37 | 38 | - name: Deploy to Netlify 39 | uses: nwtgck/actions-netlify@v3.0 40 | with: 41 | publish-dir: ./dist 42 | production-branch: main 43 | production-deploy: true 44 | github-token: ${{ secrets.GITHUB_TOKEN }} 45 | deploy-message: New Release Deploy from GitHub Actions 46 | enable-pull-request-comment: false 47 | enable-commit-comment: true 48 | overwrites-pull-request-comment: true 49 | env: 50 | NETLIFY_AUTH_TOKEN: ${{ secrets.NETLIFY_AUTH_TOKEN }} 51 | NETLIFY_SITE_ID: ${{ secrets.NETLIFY_DEMO_SITE_ID }} 52 | timeout-minutes: 1 53 | -------------------------------------------------------------------------------- /packages/client/public/assets/logo-light.svg: -------------------------------------------------------------------------------- 1 | 15 | -------------------------------------------------------------------------------- /packages/client/public/assets/logo.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /packages/client/public/assets/logo-dark.svg: -------------------------------------------------------------------------------- 1 | 15 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@unlighthouse/monorepo", 3 | "type": "module", 4 | "version": "0.17.4", 5 | "private": true, 6 | "packageManager": "pnpm@10.26.0", 7 | "license": "MIT", 8 | "scripts": { 9 | "cli": "JITI_ESM_RESOLVE=1 node packages/cli/dist/cli.mjs", 10 | "ci": "JITI_ESM_RESOLVE=1 node packages/cli/dist/ci.mjs", 11 | "ci:docs": "node packages/cli/dist/ci.mjs --site unlighthouse.dev --build-static --debug", 12 | "build": "pnpm run build:pkg", 13 | "build:docs": "cd docs && pnpm i && nuxi build", 14 | "build:pkg": "pnpm -r --filter=./packages/** run build", 15 | "stub": "JITI_ESM_RESOLVE=true && pnpm -r --parallel run stub", 16 | "lint": "eslint . --fix", 17 | "bump": "bumpp package.json packages-aliased/*/package.json packages/*/package.json --commit --push --tag", 18 | "release": "pnpm build && pnpm bump", 19 | "test": "vitest", 20 | "test:update": "vitest -u", 21 | "docs": "npm -C docs run dev", 22 | "docs:build": "npm -C docs run build", 23 | "docs:serve": "npm -C docs run serve", 24 | "test:attw": "pnpm -r run test:attw", 25 | "lint:docs": "pnpx markdownlint-cli ./docs && pnpx case-police 'docs/**/*.md' *.md", 26 | "lint:docs:fix": "pnpx markdownlint-cli ./docs --fix && pnpx case-police 'docs/**/*.md' *.md --fix" 27 | }, 28 | "devDependencies": { 29 | "@antfu/eslint-config": "catalog:", 30 | "@arethetypeswrong/cli": "catalog:", 31 | "bumpp": "catalog:", 32 | "eslint": "catalog:", 33 | "obuild": "catalog:", 34 | "typescript": "catalog:", 35 | "vite": "catalog:dependencies", 36 | "vitest": "catalog:" 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /packages/client/components/Cell/CellLargestContentfulPaint.vue: -------------------------------------------------------------------------------- 1 | 30 | 31 | 44 | -------------------------------------------------------------------------------- /packages/core/src/lighthouse.ts: -------------------------------------------------------------------------------- 1 | import type { Flags } from 'lighthouse' 2 | import type { UnlighthouseRouteReport } from './types' 3 | import { setMaxListeners } from 'node:events' 4 | import fs from 'node:fs' 5 | import { join } from 'node:path' 6 | import lighthouse from 'lighthouse' 7 | import minimist from 'minimist' 8 | 9 | setMaxListeners(0); 10 | 11 | /* 12 | * This file is intended to be run in its own process and should not rely on any global state. 13 | */ 14 | 15 | (async () => { 16 | const { routeReport, port, lighthouseOptions: lighthouseOptionsEncoded } 17 | = minimist<{ options: string, cache: boolean, routeReport: string, port: number }>(process.argv.slice(2)) 18 | 19 | let routeReportJson: UnlighthouseRouteReport 20 | try { 21 | routeReportJson = JSON.parse(routeReport) 22 | } 23 | catch (e: unknown) { 24 | console.error('Failed to parse Unlighthouse config. Please create an issue with this output.', process.argv.slice(2), e) 25 | return false 26 | } 27 | const lighthouseOptions: Flags = { 28 | ...JSON.parse(lighthouseOptionsEncoded), 29 | // always generate html / json reports 30 | output: ['html', 'json'], 31 | // we tell lighthouse the port 32 | port, 33 | } 34 | try { 35 | const runnerResult = await lighthouse(routeReportJson.route.url, lighthouseOptions) 36 | fs.writeFileSync(join(routeReportJson.artifactPath, 'lighthouse.json'), runnerResult.report[1]) 37 | fs.writeFileSync(join(routeReportJson.artifactPath, 'lighthouse.html'), runnerResult.report[0]) 38 | return true 39 | } 40 | catch (e) { 41 | console.error(e) 42 | } 43 | return false 44 | })() 45 | -------------------------------------------------------------------------------- /packages/core/src/process/lighthouse.ts: -------------------------------------------------------------------------------- 1 | import type { Flags } from 'lighthouse' 2 | import type { UnlighthouseRouteReport } from '../types' 3 | import { setMaxListeners } from 'node:events' 4 | import fs from 'node:fs' 5 | import { join } from 'node:path' 6 | import lighthouse from 'lighthouse/core/index.cjs' 7 | import minimist from 'minimist' 8 | 9 | setMaxListeners(0); 10 | 11 | /* 12 | * This file is intended to be run in its own process and should not rely on any global state. 13 | */ 14 | 15 | (async () => { 16 | const { routeReport, port, lighthouseOptions: lighthouseOptionsEncoded } 17 | = minimist<{ options: string, cache: boolean, routeReport: string, port: number }>(process.argv.slice(2)) 18 | 19 | let routeReportJson: UnlighthouseRouteReport 20 | try { 21 | routeReportJson = JSON.parse(routeReport) 22 | } 23 | catch (e: unknown) { 24 | console.error('Failed to parse Unlighthouse config. Please create an issue with this output.', process.argv.slice(2), e) 25 | return false 26 | } 27 | const lighthouseOptions: Flags = { 28 | ...JSON.parse(lighthouseOptionsEncoded), 29 | // always generate html / json reports 30 | output: ['html', 'json'], 31 | // we tell lighthouse the port 32 | port, 33 | } 34 | try { 35 | const runnerResult = await lighthouse(routeReportJson.route.url, lighthouseOptions) 36 | fs.writeFileSync(join(routeReportJson.artifactPath, 'lighthouse.json'), runnerResult.report[1]) 37 | fs.writeFileSync(join(routeReportJson.artifactPath, 'lighthouse.html'), runnerResult.report[0]) 38 | return true 39 | } 40 | catch (e) { 41 | console.error(e) 42 | } 43 | return false 44 | })() 45 | -------------------------------------------------------------------------------- /packages/unlighthouse-ci/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "unlighthouse-ci", 3 | "type": "module", 4 | "version": "0.17.4", 5 | "description": "Delightfully scan your entire website with Google Lighthouse. Navigate your performance, accessibility and SEO.", 6 | "license": "MIT", 7 | "homepage": "https://github.com/harlan-zw/unlighthouse#readme", 8 | "repository": { 9 | "type": "git", 10 | "url": "git+https://github.com/harlan-zw/unlighthouse.git", 11 | "directory": "packages/unlighthouse-ci" 12 | }, 13 | "bugs": { 14 | "url": "https://github.com/harlan-zw/unlighthouse/issues" 15 | }, 16 | "keywords": [ 17 | "lighthouse", 18 | "audit", 19 | "seo", 20 | "performance", 21 | "speed" 22 | ], 23 | "sideEffects": false, 24 | "exports": { 25 | ".": { 26 | "types": "./dist/index.d.ts", 27 | "import": "./dist/index.mjs" 28 | } 29 | }, 30 | "main": "dist/index.mjs", 31 | "module": "dist/index.mjs", 32 | "types": "index.d.ts", 33 | "bin": { 34 | "unlighthouse": "bin/unlighthouse.mjs", 35 | "unlighthouse-ci": "bin/unlighthouse-ci.mjs" 36 | }, 37 | "files": [ 38 | "*.d.ts", 39 | "bin", 40 | "dist" 41 | ], 42 | "engines": { 43 | "node": ">=20" 44 | }, 45 | "scripts": { 46 | "build": "obuild", 47 | "stub": "obuild --stub" 48 | }, 49 | "peerDependenciesMeta": { 50 | "puppeteer": { 51 | "optional": true 52 | }, 53 | "vue": { 54 | "optional": true 55 | } 56 | }, 57 | "dependencies": { 58 | "@unlighthouse/cli": "workspace:*", 59 | "@unlighthouse/client": "workspace:*", 60 | "@unlighthouse/core": "workspace:*" 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /packages/client/components/Cell/CellImage.vue: -------------------------------------------------------------------------------- 1 | 40 | 41 | 52 | -------------------------------------------------------------------------------- /docs/1.guide/guides/route-definitions.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: "Route Definitions" 3 | description: "Configure route discovery and custom sampling patterns for better page organization and intelligent scanning." 4 | navigation: 5 | title: "Route Definitions" 6 | --- 7 | 8 | ## Introduction 9 | 10 | Route definitions improve scanning intelligence by mapping URLs to source files and enabling better [dynamic sampling](/guide/guides/dynamic-sampling). Unlighthouse automatically discovers routes in framework integrations, but CLI users may need manual configuration. 11 | 12 | ## Pages directory 13 | 14 | By default, the `pages/` dir is scanned for files with extensions `.vue` and `.md`, from the `root` directory. 15 | 16 | If your project has a different setup you can modify the configuration. 17 | 18 | ```ts 19 | import { defineUnlighthouseConfig } from 'unlighthouse/config' 20 | 21 | export default defineUnlighthouseConfig({ 22 | root: './app', 23 | discovery: { 24 | pagesDir: 'routes', 25 | fileExtensions: ['jsx', 'md'], 26 | }, 27 | }) 28 | ``` 29 | 30 | ## Custom sampling 31 | 32 | When you have URL patterns which don't use URL segments or the mapping is failing, it can be useful to map the sampling 33 | yourself. 34 | 35 | By using the `customSampling` option you map regex to a route definition. 36 | 37 | In the below example we will map any URL such as `/q-search-query`, `/q-where-is-the-thing` to a single route 38 | definition, , which allows the sampling to work. 39 | 40 | ```ts 41 | export default defineUnlighthouseConfig({ 42 | scanner: { 43 | customSampling: { 44 | '/q-(.*?)': { 45 | name: 'search-query', 46 | }, 47 | }, 48 | }, 49 | }) 50 | ``` 51 | -------------------------------------------------------------------------------- /packages/core/README.md: -------------------------------------------------------------------------------- 1 | # @unlighthouse/core 2 | 3 | The core engine of [Unlighthouse](https://github.com/harlan-zw/unlighthouse) that handles website scanning, Lighthouse execution, and report generation. 4 | 5 | ## Usage 6 | 7 | ### Basic Usage 8 | 9 | ```ts 10 | import { createUnlighthouse } from '@unlighthouse/core' 11 | 12 | const unlighthouse = await createUnlighthouse({ 13 | site: 'https://example.com', 14 | debug: true, 15 | scanner: { 16 | device: 'desktop', 17 | } 18 | }) 19 | 20 | await unlighthouse.start() 21 | ``` 22 | 23 | ### With Custom Provider 24 | 25 | ```ts 26 | import { createUnlighthouse } from '@unlighthouse/core' 27 | 28 | const unlighthouse = await createUnlighthouse( 29 | { /* user config */ }, 30 | { 31 | name: 'custom', 32 | routeDefinitions: () => generateRouteDefinitions(), 33 | } 34 | ) 35 | ``` 36 | 37 | ### Hooks 38 | 39 | ```ts 40 | import { useUnlighthouse } from '@unlighthouse/core' 41 | 42 | const { hooks } = useUnlighthouse() 43 | 44 | hooks.hook('task-complete', (path, response) => { 45 | console.log('Task completed for:', path) 46 | }) 47 | ``` 48 | 49 | ## API 50 | 51 | - `createUnlighthouse(userConfig, provider?)` - Initialize Unlighthouse 52 | - `useUnlighthouse()` - Access the global Unlighthouse context 53 | - `generateClient(options)` - Generate static client files 54 | 55 | ## Documentation 56 | 57 | - [API Reference](https://unlighthouse.dev/api/index.html) 58 | - [Configuration Guide](https://unlighthouse.dev/guide/config.html) 59 | - [Puppeteer Configuration](https://unlighthouse.dev/guide/puppeteer.html) 60 | 61 | ## License 62 | 63 | MIT License © 2021-PRESENT [Harlan Wilton](https://github.com/harlan-zw) 64 | -------------------------------------------------------------------------------- /docs/1.guide/guides/device.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: "Device Configuration" 3 | description: "Configure device emulation settings for mobile and desktop scanning with custom dimensions and throttling options." 4 | navigation: 5 | title: "Device Configuration" 6 | --- 7 | 8 | ## Introduction 9 | 10 | Unlighthouse uses device emulation to test how your website performs on different screen sizes and network conditions. By default, scans emulate a mobile device (375x667) without throttling. 11 | 12 | Configuration aliases make it easy to switch between common device types and settings. 13 | 14 | ## Device Types 15 | 16 | ### Desktop Scanning 17 | 18 | ```ts 19 | import { defineUnlighthouseConfig } from 'unlighthouse/config' 20 | 21 | export default defineUnlighthouseConfig({ 22 | scanner: { 23 | device: 'desktop', 24 | }, 25 | }) 26 | ``` 27 | 28 | ### Mobile Scanning (Default) 29 | 30 | ```ts 31 | export default defineUnlighthouseConfig({ 32 | scanner: { 33 | device: 'mobile', 34 | }, 35 | }) 36 | ``` 37 | 38 | ## Custom Dimensions 39 | 40 | Test specific viewport sizes for responsive breakpoints: 41 | 42 | ```ts 43 | export default defineUnlighthouseConfig({ 44 | lighthouseOptions: { 45 | screenEmulation: { 46 | width: 1800, 47 | height: 1000, 48 | }, 49 | }, 50 | }) 51 | ``` 52 | 53 | ## Network Throttling 54 | 55 | Throttling simulates slower network and CPU conditions for more realistic performance testing: 56 | 57 | ```ts 58 | export default defineUnlighthouseConfig({ 59 | scanner: { 60 | throttle: true, 61 | }, 62 | }) 63 | ``` 64 | 65 | ::note 66 | Throttling is automatically enabled for production sites and disabled for localhost by default. 67 | :: 68 | -------------------------------------------------------------------------------- /packages/client/components/Audit/AuditResult.vue: -------------------------------------------------------------------------------- 1 | 44 | 45 | 53 | 54 | 61 | -------------------------------------------------------------------------------- /packages/client/logic/offline.ts: -------------------------------------------------------------------------------- 1 | import { computed, onMounted, ref } from 'vue' 2 | import { isStatic } from './static' 3 | 4 | export const isServerAvailable = ref(true) 5 | export const hasAttemptedConnection = ref(false) 6 | 7 | export const isOfflineMode = computed(() => { 8 | if (isStatic) 9 | return false 10 | return hasAttemptedConnection.value && !isServerAvailable.value 11 | }) 12 | 13 | export const hasNoData = computed(() => { 14 | // Check if we have no payload data in static mode, or no server connection in dynamic mode 15 | if (isStatic) { 16 | return !window.__unlighthouse_payload?.reports?.length 17 | } 18 | return isOfflineMode.value 19 | }) 20 | 21 | export function checkServerConnection() { 22 | if (isStatic) 23 | return Promise.resolve(true) 24 | 25 | // Try to fetch a simple endpoint to check if server is available 26 | return fetch('/api/health', { 27 | method: 'GET', 28 | cache: 'no-cache', 29 | }) 30 | .then(() => { 31 | isServerAvailable.value = true 32 | return true 33 | }) 34 | .catch(() => { 35 | isServerAvailable.value = false 36 | return false 37 | }) 38 | .finally(() => { 39 | hasAttemptedConnection.value = true 40 | }) 41 | } 42 | 43 | export function useOfflineDetection() { 44 | onMounted(async () => { 45 | if (!isStatic) { 46 | await checkServerConnection() 47 | 48 | // Set up periodic health checks 49 | setInterval(async () => { 50 | await checkServerConnection() 51 | }, 30000) // Check every 30 seconds 52 | } 53 | }) 54 | 55 | return { 56 | isOfflineMode, 57 | hasNoData, 58 | isServerAvailable, 59 | checkServerConnection, 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /packages/core/src/util/cliFormatting.ts: -------------------------------------------------------------------------------- 1 | import type { BoxOpts } from 'consola/utils' 2 | import { colorize, box as makeBox } from 'consola/utils' 3 | import wrapAnsi from 'wrap-ansi' 4 | 5 | /** 6 | * Copied from https://github.com/nuxt/nuxt.js/blob/dev/packages/cli/src/utils/formatting.js 7 | */ 8 | 9 | export const maxCharsPerLine = () => (process.stdout.columns || 100) * 80 / 100 10 | 11 | export function indent(count: number, chr = ' ') { 12 | return chr.repeat(count) 13 | } 14 | 15 | export function indentLines(string: string, spaces: number, firstLineSpaces: number) { 16 | const lines = Array.isArray(string) ? string : string.split('\n') 17 | let s = '' 18 | if (lines.length) { 19 | const i0 = indent(firstLineSpaces === undefined ? spaces : firstLineSpaces) 20 | s = i0 + lines.shift() 21 | } 22 | if (lines.length) { 23 | const i = indent(spaces) 24 | s += `\n${lines.map(l => i + l).join('\n')}` 25 | } 26 | return s 27 | } 28 | 29 | export function foldLines(string: string, spaces: number, firstLineSpaces: number, charsPerLine = maxCharsPerLine()) { 30 | return indentLines(wrapAnsi(string, charsPerLine), spaces, firstLineSpaces) 31 | } 32 | 33 | export function box(message: string, title: string, options?: BoxOpts) { 34 | return `${makeBox([ 35 | title, 36 | '', 37 | colorize('white', foldLines(message, 0, 0, maxCharsPerLine())), 38 | ].join('\n'), Object.assign({ 39 | borderColor: 'white', 40 | borderStyle: 'round', 41 | padding: 1, 42 | margin: 1, 43 | }, options))}\n` 44 | } 45 | 46 | export function successBox(message: string, title: string) { 47 | return box(message, title, { 48 | style: { 49 | borderColor: 'green', 50 | }, 51 | }) 52 | } 53 | -------------------------------------------------------------------------------- /docs/1.guide/guides/lighthouse.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: "Lighthouse Configuration" 3 | description: "Customize Google Lighthouse audit settings, categories, and performance thresholds within Unlighthouse scans." 4 | navigation: 5 | title: "Lighthouse Config" 6 | --- 7 | 8 | ## Introduction 9 | 10 | Unlighthouse provides direct access to Google Lighthouse configuration through the `lighthouseOptions` key. You can customize audit categories, performance thresholds, and scanning behavior. 11 | 12 | ```ts 13 | import { defineUnlighthouseConfig } from 'unlighthouse/config' 14 | 15 | export default defineUnlighthouseConfig({ 16 | lighthouseOptions: { 17 | throttlingMethod: 'devtools', 18 | }, 19 | }) 20 | ``` 21 | 22 | For complete options, see the [Lighthouse Configuration docs](https://github.com/GoogleChrome/lighthouse/blob/master/docs/configuration.md). 23 | 24 | ## Aliases 25 | 26 | Unlighthouse aims to minimise and simplify configuration, where possible. 27 | 28 | For this reason, a number of configurations aliases are provided for your convenience. 29 | 30 | - [Switching device: mobile and desktop]() 31 | - [Toggle Throttling]() 32 | 33 | You can always configure lighthouse directly if you are comfortable with the configuration. 34 | 35 | ## Selecting Categories 36 | 37 | By default, Unlighthouse will scan the categories: `'performance', 'accessibility', 'best-practices', 'seo'`. 38 | 39 | It can be useful to remove certain categories from being scanned to improve scan times. The Unlighthouse UI will adapt 40 | to any categories you select. 41 | 42 | **Only Performance** 43 | 44 | ```ts 45 | export default defineUnlighthouseConfig({ 46 | lighthouseOptions: { 47 | onlyCategories: ['performance'], 48 | }, 49 | }) 50 | ``` 51 | -------------------------------------------------------------------------------- /docs/integration-deprecations.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: "Integration Deprecations" 3 | icon: carbon:warning-alt 4 | description: "Build tool integrations are deprecated in v1.0. Learn about migration paths and alternatives." 5 | navigation: false 6 | --- 7 | 8 | ## Introduction 9 | 10 | The following build tool integrations are deprecated and will be removed in v1.0: 11 | 12 | - `@unlighthouse/nuxt` 13 | - `@unlighthouse/vite` 14 | - `@unlighthouse/webpack` 15 | 16 | ::warning 17 | Start migrating to [CLI](/integrations/cli) or [CI](/integrations/ci) integrations for continued support. 18 | :: 19 | 20 | ## Background 21 | 22 | When Unlighthouse was being developed, the goal was to make it as simple as possible to use with your development site. 23 | 24 | To allow for this, 25 | integrations 26 | where added that set up Unlighthouse automatically for you. 27 | 28 | This provided the site URL, automatic rescans on page updates and route discovery, which allowed for smarter sampling of dynamic routes. 29 | 30 | ## Why Deprecate? 31 | 32 | Simply, the integrations are too difficult to maintain, error-prone and provide low-value. 33 | 34 | In nearly all raised issues related to integration, they weren't needed and the CLI could be used instead. 35 | 36 | ## Upgrading 37 | 38 | You should remove any of the following packages from your project. 39 | 40 | - `@unlighthouse/nuxt` 41 | - `@unlighthouse/vite` 42 | - `@unlighthouse/webpack` 43 | 44 | Instead, you should simply use the CLI. 45 | 46 | ```bash 47 | npx unlighthouse --site localhost:3000 48 | ``` 49 | 50 | The HMR integration be solved by manually rescanning routes using the UI. 51 | 52 | The route discovery 53 | will still work when scanned in the root directory or an app with `pages`. 54 | -------------------------------------------------------------------------------- /packages/cli/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@unlighthouse/cli", 3 | "type": "module", 4 | "version": "0.17.4", 5 | "description": "CLI for Unlighthouse", 6 | "license": "MIT", 7 | "homepage": "https://github.com/harlan-zw/unlighthouse#readme", 8 | "repository": { 9 | "type": "git", 10 | "url": "git+https://github.com/harlan-zw/unlighthouse.git", 11 | "directory": "packages/cli" 12 | }, 13 | "bugs": { 14 | "url": "https://github.com/harlan-zw/unlighthouse/issues" 15 | }, 16 | "keywords": [ 17 | "lighthouse", 18 | "audit", 19 | "seo", 20 | "performance", 21 | "cli", 22 | "unlighthouse" 23 | ], 24 | "sideEffects": false, 25 | "exports": { 26 | ".": { 27 | "import": "./dist/cli.mjs" 28 | }, 29 | "./ci": { 30 | "import": "./dist/ci.mjs" 31 | } 32 | }, 33 | "main": "dist/cli.mjs", 34 | "module": "dist/cli.mjs", 35 | "bin": { 36 | "unlighthouse-ci": "bin/unlighthouse-ci.mjs" 37 | }, 38 | "files": [ 39 | "*.d.ts", 40 | "bin", 41 | "dist" 42 | ], 43 | "scripts": { 44 | "build": "obuild", 45 | "stub": "obuild --stub" 46 | }, 47 | "dependencies": { 48 | "@lhci/utils": "catalog:dependencies", 49 | "@unlighthouse/client": "workspace:*", 50 | "@unlighthouse/core": "workspace:*", 51 | "@unlighthouse/server": "workspace:*", 52 | "better-opn": "catalog:dependencies", 53 | "cac": "catalog:dependencies", 54 | "consola": "catalog:dependencies", 55 | "defu": "catalog:dependencies", 56 | "fs-extra": "catalog:dependencies", 57 | "globby": "catalog:dependencies", 58 | "lodash-es": "catalog:dependencies", 59 | "pathe": "catalog:dependencies", 60 | "std-env": "catalog:dependencies" 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /docs/1.guide/guides/docker.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: "Docker Support" 3 | description: "Run Unlighthouse in Docker containers for consistent CI/CD environments with proper Chromium configuration." 4 | navigation: 5 | title: "Docker" 6 | --- 7 | 8 | ## Introduction 9 | 10 | Unlighthouse supports Docker environments for consistent CI/CD deployments. Docker requires special Puppeteer configuration due to sandboxing restrictions. 11 | 12 | ::warning 13 | Docker support is community-maintained and experimental. Use the CI integration for best results. 14 | :: 15 | 16 | ## Unlighthouse Config 17 | 18 | It's recommended you only use the `@unlighthouse/ci` with Docker. Hosting the client does not have known support. 19 | 20 | You will need to remove the Chrome sandbox in a Docker environment, this will require using an `unlighthouse.config.ts` file. 21 | 22 | ```ts 23 | import { defineUnlighthouseConfig } from 'unlighthouse/config' 24 | 25 | export default defineUnlighthouseConfig({ 26 | puppeteerOptions: { 27 | headless: true, 28 | args: [ 29 | '--no-sandbox', 30 | '--disable-setuid-sandbox', 31 | '--disable-gpu', 32 | '--ignore-certificate-errors', 33 | ], 34 | }, 35 | }) 36 | ``` 37 | 38 | If you're using the `unlighthouse` binary instead of the CI integration, then you will need to tell Unlighthouse not to use the server and close when 39 | the reports are finished. 40 | 41 | ```ts 42 | export default defineUnlighthouseConfig({ 43 | server: { 44 | open: false, 45 | }, 46 | hooks: { 47 | 'worker-finished': async () => { 48 | process.exit(0) 49 | }, 50 | }, 51 | }) 52 | ``` 53 | 54 | ## Docker File 55 | 56 | Please see the following community repos: 57 | 58 | - [indykoning—Unlighthouse Docker](https://github.com/indykoning/unlighthouse-docker) 59 | -------------------------------------------------------------------------------- /packages/cli/src/reporters/types.ts: -------------------------------------------------------------------------------- 1 | import type { UnlighthouseColumn, UnlighthouseTabs } from '../../../core/src' 2 | 3 | export interface CategoryScore { 4 | key: string 5 | id: string 6 | title: string 7 | score: number 8 | } 9 | 10 | export interface MetricScore { 11 | numericValue: number 12 | displayValue: string 13 | } 14 | 15 | export interface SimpleRouteReport { 16 | path: string 17 | score?: number | string | null 18 | [key: string]: string | number | null 19 | } 20 | 21 | export interface ExpandedRouteReport extends SimpleRouteReport { 22 | categories: { 23 | [key: string]: CategoryScore 24 | } 25 | metrics: { 26 | [key: string]: MetricScore 27 | } 28 | } 29 | 30 | export interface CategoryAverageScore { 31 | averageScore: number 32 | } 33 | 34 | export interface MetricAverageScore { 35 | averageNumericValue: number 36 | } 37 | 38 | export interface MetricMetadata { 39 | id: string 40 | title: string 41 | description: string 42 | numericUnit: string 43 | } 44 | 45 | export interface CategoryMetadata { 46 | id: string 47 | title: string 48 | } 49 | 50 | export interface ReportJsonExpanded { 51 | summary: { 52 | score: number 53 | categories: { 54 | [key: string]: CategoryAverageScore 55 | } 56 | metrics: { 57 | [key: string]: MetricAverageScore 58 | } 59 | } 60 | routes: ExpandedRouteReport[] 61 | metadata: { 62 | metrics: { 63 | [key: string]: MetricMetadata 64 | } 65 | categories: { 66 | [key: string]: CategoryMetadata 67 | } 68 | } 69 | } 70 | 71 | export type ReportJsonSimple = SimpleRouteReport[] 72 | 73 | export type ReporterConfig = Partial<{ 74 | columns: Record 75 | lhciHost: string 76 | lhciBuildToken: string 77 | lhciAuth: string 78 | }> 79 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Created by .ignore support plugin (hsz.mobi) 2 | ### Node template 3 | # Logs 4 | logs 5 | *.log 6 | npm-debug.log* 7 | yarn-debug.log* 8 | yarn-error.log* 9 | 10 | # Runtime data 11 | pids 12 | *.pid 13 | *.seed 14 | *.pid.lock 15 | 16 | # Directory for instrumented libs generated by jscoverage/JSCover 17 | lib-cov 18 | 19 | # Coverage directory used by tools like istanbul 20 | coverage 21 | 22 | # nyc test coverage 23 | .nyc_output 24 | 25 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) 26 | .grunt 27 | 28 | # Bower dependency directory (https://bower.io/) 29 | bower_components 30 | 31 | # node-waf configuration 32 | .lock-wscript 33 | 34 | # Compiled binary addons (https://nodejs.org/api/addons.html) 35 | build/Release 36 | 37 | # Dependency directories 38 | node_modules/ 39 | jspm_packages/ 40 | 41 | # TypeScript v1 declaration files 42 | typings/ 43 | 44 | # Optional npm cache directory 45 | .npm 46 | 47 | # Optional eslint cache 48 | .eslintcache 49 | 50 | # Optional REPL history 51 | .node_repl_history 52 | 53 | # Output of 'npm pack' 54 | *.tgz 55 | 56 | # Yarn Integrity file 57 | .yarn-integrity 58 | 59 | # dotenv environment variables file 60 | .env 61 | 62 | # parcel-bundler cache (https://parceljs.org/) 63 | .cache 64 | 65 | # next.js build output 66 | .next 67 | 68 | # nuxt.js build output 69 | .nuxt 70 | 71 | # Nuxt generate 72 | dist 73 | 74 | # vuepress build output 75 | .vuepress/dist 76 | 77 | # Serverless directories 78 | .serverless 79 | 80 | # IDE 81 | .idea 82 | /*.d.ts 83 | 84 | **/.unlighthouse 85 | 86 | **/.cache 87 | 88 | 89 | reports 90 | 91 | .vscode/ 92 | .tmp/ 93 | 94 | .nitro 95 | .output 96 | 97 | auto-imports.d.ts 98 | components.d.ts 99 | 100 | .data -------------------------------------------------------------------------------- /docs/1.guide/guides/common-errors.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: "Common Errors" 3 | description: "Troubleshoot common issues encountered when running Unlighthouse scans, including browser connection and environment problems." 4 | navigation: 5 | title: "Common Errors" 6 | --- 7 | 8 | ## Introduction 9 | 10 | This guide covers the most common issues encountered when running Unlighthouse scans and their solutions. Always ensure you're using the latest version before troubleshooting. 11 | 12 | ::tip 13 | For general debugging techniques, see the [Debugging Guide](/guide/guides/debugging). 14 | :: 15 | 16 | ## `connect ECONNREFUSED 127.0.0.1:` 17 | 18 | **Example** 19 | 20 | > Error: Unable to launch browser for worker, error message: connect ECONNREFUSED 127.0.0.1:51667 21 | 22 | This error is thrown when Chromium is unable to launch. This happens when puppeteer is unable to connect to the browser. 23 | This can be from a number of reasons: 24 | 25 | - The environment is not configured correctly, likely when using Windows and WSL. 26 | - You have a firewall or antivirus blocking Chrome or Chromium from launching or connecting to the required port. 27 | - You are using an unsupported version of Chrome or Chromium. 28 | 29 | **Windows and WSL Solution** 30 | 31 | - Install Puppeteer on WSL following the [documentation](https://pptr.dev/troubleshooting#running-puppeteer-on-wsl-windows-subsystem-for-linux). 32 | - Install Chrome in WSL following the [documentation](https://learn.microsoft.com/en-us/windows/wsl/tutorials/gui-apps#install-google-chrome-for-linux). 33 | 34 | **Other Environments** 35 | 36 | - You can try disabling the system Chrome, instead using the fallback. 37 | 38 | ```ts 39 | import { defineUnlighthouseConfig } from 'unlighthouse/config' 40 | 41 | export default defineUnlighthouseConfig({ 42 | chrome: { 43 | useSystem: false, 44 | }, 45 | }) 46 | ``` 47 | -------------------------------------------------------------------------------- /packages/unlighthouse/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "unlighthouse", 3 | "type": "module", 4 | "version": "0.17.4", 5 | "description": "Delightfully scan your entire website with Google Lighthouse. Navigate your performance, accessibility and SEO.", 6 | "license": "MIT", 7 | "homepage": "https://github.com/harlan-zw/unlighthouse#readme", 8 | "repository": { 9 | "type": "git", 10 | "url": "git+https://github.com/harlan-zw/unlighthouse.git", 11 | "directory": "packages/unlighthouse" 12 | }, 13 | "bugs": { 14 | "url": "https://github.com/harlan-zw/unlighthouse/issues" 15 | }, 16 | "keywords": [ 17 | "lighthouse", 18 | "audit", 19 | "seo", 20 | "performance", 21 | "speed" 22 | ], 23 | "sideEffects": false, 24 | "exports": { 25 | ".": { 26 | "types": "./types.d.mts", 27 | "import": "./dist/index.mjs" 28 | }, 29 | "./config": "./config.mjs", 30 | "./package.json": "./package.json" 31 | }, 32 | "main": "./dist/index.mjs", 33 | "types": "./types.d.ts", 34 | "typesVersions": { 35 | "*": { 36 | "config": [ 37 | "dist/config" 38 | ] 39 | } 40 | }, 41 | "bin": { 42 | "unlighthouse": "bin/unlighthouse.mjs", 43 | "unlighthouse-ci": "bin/unlighthouse-ci.mjs" 44 | }, 45 | "files": [ 46 | "config.d.mts", 47 | "config.mjs", 48 | "dist", 49 | "types.d.mts", 50 | "types.d.ts" 51 | ], 52 | "engines": { 53 | "node": ">=20" 54 | }, 55 | "scripts": { 56 | "build": "obuild", 57 | "stub": "obuild --stub", 58 | "test:attw": "attw --pack" 59 | }, 60 | "peerDependenciesMeta": { 61 | "puppeteer": { 62 | "optional": true 63 | }, 64 | "vue": { 65 | "optional": true 66 | } 67 | }, 68 | "dependencies": { 69 | "@unlighthouse/cli": "workspace:*", 70 | "@unlighthouse/client": "workspace:*", 71 | "@unlighthouse/core": "workspace:*" 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /packages/client/components/Popover/PopoverActions.vue: -------------------------------------------------------------------------------- 1 | 6 | 7 | 48 | -------------------------------------------------------------------------------- /packages/client/components/Cell/CellLayoutShift.vue: -------------------------------------------------------------------------------- 1 | 29 | 30 | 48 | -------------------------------------------------------------------------------- /packages/core/src/router/broadcasting.ts: -------------------------------------------------------------------------------- 1 | import type { IncomingMessage } from 'node:http' 2 | import type { Socket } from 'node:net' 3 | import { Buffer } from 'node:buffer' 4 | import { WebSocketServer } from 'ws' 5 | import { useUnlighthouse } from '../unlighthouse' 6 | 7 | /** 8 | * When certain hooks are triggered we need to broadcast data via the web socket. 9 | */ 10 | export function createBroadcastingEvents() { 11 | const { hooks, ws } = useUnlighthouse() 12 | 13 | // ws may not be set, for example in a CI environment 14 | if (!ws) 15 | return 16 | 17 | hooks.hook('task-started', (path, response) => { 18 | if (response.tasks.inspectHtmlTask === 'completed') 19 | ws.broadcast({ response }) 20 | }) 21 | hooks.hook('task-complete', (path, response) => { 22 | if (response.tasks.inspectHtmlTask === 'completed') 23 | ws.broadcast({ response }) 24 | }) 25 | hooks.hook('task-added', (path, response) => { 26 | if (response.tasks.inspectHtmlTask === 'completed') 27 | ws.broadcast({ response }) 28 | }) 29 | } 30 | 31 | export class WS { 32 | private wss: WebSocketServer 33 | constructor() { 34 | this.wss = new WebSocketServer({ noServer: true }) 35 | } 36 | 37 | serve(req: IncomingMessage) { 38 | this.handleUpgrade(req, req.socket) 39 | } 40 | 41 | handleUpgrade(request: IncomingMessage, socket: Socket) { 42 | return this.wss.handleUpgrade(request, socket, Buffer.alloc(0), (client) => { 43 | this.wss.emit('connection', client, request) 44 | }) 45 | } 46 | 47 | /** 48 | * Publish event and data to all connected clients 49 | * @param {object} data 50 | */ 51 | broadcast(data: Record) { 52 | const jsonData = JSON.stringify(data) 53 | 54 | for (const client of this.wss.clients) { 55 | try { 56 | client.send(jsonData) 57 | } 58 | catch { 59 | // Ignore error (if client not ready to receive event) 60 | } 61 | } 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /test/ci.test.ts: -------------------------------------------------------------------------------- 1 | import { join, resolve } from 'node:path' 2 | import fs from 'fs-extra' 3 | import { afterAll, beforeAll, describe, expect, it } from 'vitest' 4 | import { execa } from 'execa' 5 | 6 | export const cacheDir = resolve(__dirname, '.cache') 7 | export const ci = resolve(__dirname, '../packages/unlighthouse/bin/unlighthouse-ci.mjs') 8 | 9 | beforeAll(async () => { 10 | await fs.remove(cacheDir) 11 | }) 12 | 13 | afterAll(async () => { 14 | await fs.remove(cacheDir) 15 | }) 16 | 17 | describe('ci', () => { 18 | it('tests harlanzw.com', async () => { 19 | const { output } = await runCli(resolve(__dirname, 'fixtures/harlanzw.config.ts')) 20 | 21 | expect(output[0].path).toBeDefined() 22 | expect(output[0].score).toBeDefined() 23 | }) 24 | 25 | it('tests harlanzw.com and generate json expanded', async () => { 26 | const { output } = await runCli(resolve(__dirname, 'fixtures/harlanzw-json-expanded.config.ts')) 27 | 28 | expect(output.summary).toBeDefined() 29 | expect(output.summary.score).toBeDefined() 30 | expect(output.metadata).toBeDefined() 31 | expect(output.routes[0].path).toBeDefined() 32 | expect(output.routes[0].score).toBeDefined() 33 | expect(output.routes[0].categories).toBeDefined() 34 | }) 35 | }) 36 | 37 | async function runCli(configFileFixture: string) { 38 | const testDir = resolve(cacheDir, Date.now().toString()) 39 | 40 | await fs.ensureDir(testDir) 41 | 42 | const config = await fs.readFile(configFileFixture, 'utf8') 43 | await fs.writeFile(join(testDir, 'unlighthouse.config.ts'), config) 44 | 45 | const { exitCode, stdout, stderr } = await execa('node', [ci, '--root', testDir, '--debug', '--site', 'harlanzw.com'], { 46 | cwd: testDir, 47 | }) 48 | 49 | const logs = stdout + stderr 50 | if (exitCode !== 0) 51 | throw new Error(logs) 52 | 53 | const output = await fs.readJson(resolve(testDir, '.unlighthouse', 'ci-result.json')) 54 | 55 | return { 56 | output, 57 | logs, 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /packages/cli/src/cli.ts: -------------------------------------------------------------------------------- 1 | import type { CliOptions } from './types' 2 | import { setMaxListeners } from 'node:events' 3 | import { createUnlighthouse, useLogger } from '@unlighthouse/core' 4 | import { createServer } from '@unlighthouse/server' 5 | import open from 'better-opn' 6 | import createCli from './createCli' 7 | import { pickOptions, validateHost, validateOptions } from './util' 8 | 9 | const cli = createCli() 10 | 11 | const { options } = cli.parse() as unknown as { options: CliOptions } 12 | 13 | async function run() { 14 | const start = new Date() 15 | if (options.help || options.version) 16 | return 17 | 18 | setMaxListeners(0) 19 | 20 | const unlighthouse = await createUnlighthouse( 21 | { 22 | ...pickOptions(options), 23 | hooks: { 24 | 'resolved-config': async (config) => { 25 | await validateHost(config) 26 | }, 27 | }, 28 | }, 29 | { name: 'cli' }, 30 | ) 31 | 32 | validateOptions(unlighthouse.resolvedConfig) 33 | 34 | const { server, app } = await createServer() 35 | await unlighthouse.setServerContext({ url: server.url, server: server.server, app }) 36 | const { routes } = await unlighthouse.start() 37 | const logger = useLogger() 38 | if (!routes.length) { 39 | logger.error('Failed to queue routes for scanning. Please check the logs with debug enabled.') 40 | process.exit(1) 41 | } 42 | 43 | unlighthouse.hooks.hook('worker-finished', async () => { 44 | const end = new Date() 45 | const seconds = Math.round((end.getTime() - start.getTime()) / 1000) 46 | 47 | // Clear the progress display 48 | unlighthouse.worker.clearProgressDisplay() 49 | logger.success(`Unlighthouse has finished scanning ${unlighthouse.resolvedConfig.site}: ${unlighthouse.worker.reports().length} routes in ${seconds}s.`) 50 | await unlighthouse.worker.cluster.close().catch(() => {}) 51 | }) 52 | 53 | if (unlighthouse.resolvedConfig.server.open) 54 | await open(unlighthouse.runtimeSettings.clientUrl) 55 | } 56 | 57 | run() 58 | -------------------------------------------------------------------------------- /docs/1.guide/guides/chrome-dependency.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: "Chrome Dependency" 3 | description: "Configure Chrome browser settings for Unlighthouse scanning, including system Chrome usage and custom installations." 4 | navigation: 5 | title: "Chrome Dependency" 6 | --- 7 | 8 | ## Introduction 9 | 10 | Unlighthouse automatically detects and uses your system's Chrome installation to keep package size minimal. When Chrome isn't available, it downloads a compatible Chromium binary as a fallback. 11 | 12 | ## Disabling system Chrome 13 | 14 | You can disable the system chrome usage by modifying the `chrome.useSystem` flag. 15 | 16 | This will make Unlighthouse download and use the latest Chrome binary instead. 17 | 18 | ```ts 19 | import { defineUnlighthouseConfig } from 'unlighthouse/config' 20 | 21 | export default defineUnlighthouseConfig({ 22 | chrome: { 23 | useSystem: false 24 | }, 25 | }) 26 | ``` 27 | 28 | ## Customizing the fallback installer 29 | 30 | When Chrome can't be found on your system or if the `chrome.useSystem: false` flag is passed, then a fallback will be attempted. 31 | 32 | This fallback will download a chrome binary for your system and use that path. 33 | 34 | There are a number of options you can customize on this. 35 | 36 | - `chrome.useDownloadFallback` - Disables the fallback installer 37 | - `chrome.downloadFallbackVersion` - Which version of chromium to use (default `1095492`) 38 | - `chrome.downloadFallbackCacheDir` - Where the binary should be saved (default `$home/.unlighthouse`) 39 | 40 | ```ts 41 | export default defineUnlighthouseConfig({ 42 | chrome: { 43 | useDownloadFallback: true, 44 | downloadFallbackVersion: '1095492', 45 | downloadFallbackCacheDir: '/tmp/unlighthouse', 46 | }, 47 | }) 48 | ``` 49 | 50 | ## Using your own chrome path 51 | 52 | You can provide your own chrome path by setting `puppeteerOptions.executablePath`. 53 | 54 | ```ts 55 | export default defineUnlighthouseConfig({ 56 | puppeteerOptions: { 57 | executablePath: '/usr/bin/chrome', 58 | }, 59 | }) 60 | ``` 61 | -------------------------------------------------------------------------------- /packages/cli/README.md: -------------------------------------------------------------------------------- 1 | # @unlighthouse/cli 2 | 3 | The command-line interface and CI integration for [Unlighthouse](https://github.com/harlan-zw/unlighthouse), enabling automated website scanning in development and deployment workflows. 4 | 5 | ## Installation 6 | 7 | ```bash 8 | # Use directly with npx 9 | npx unlighthouse --site https://example.com 10 | 11 | # Or install globally 12 | npm install -g @unlighthouse/cli 13 | ``` 14 | 15 | ## Usage 16 | 17 | ### Basic Scanning 18 | 19 | ```bash 20 | # Scan a website 21 | unlighthouse --site https://example.com 22 | 23 | # Enable debug mode 24 | unlighthouse --site https://example.com --debug 25 | 26 | # Mobile device simulation 27 | unlighthouse --site https://example.com --mobile 28 | ``` 29 | 30 | ### Advanced Options 31 | 32 | ```bash 33 | # Multiple samples with throttling 34 | unlighthouse --site https://example.com --samples 3 --throttle 35 | 36 | # Custom URLs and exclusions 37 | unlighthouse --site https://example.com --urls /home,/about,/contact --exclude-urls /admin/* 38 | 39 | # With custom configuration 40 | unlighthouse --site https://example.com --config-file ./my-config.ts 41 | ``` 42 | 43 | ### CI Integration 44 | 45 | ```bash 46 | # CI mode with exit codes for failed audits 47 | unlighthouse-ci --site https://example.com --budget 75 48 | ``` 49 | 50 | ## Configuration 51 | 52 | Create `unlighthouse.config.ts` in your project root: 53 | 54 | ```ts 55 | import { defineUnlighthouseConfig } from 'unlighthouse/config' 56 | 57 | export default defineUnlighthouseConfig({ 58 | site: 'https://example.com', 59 | debug: true, 60 | scanner: { 61 | device: 'desktop', 62 | throttle: false, 63 | }, 64 | lighthouseOptions: { 65 | onlyCategories: ['performance', 'accessibility'], 66 | } 67 | }) 68 | ``` 69 | 70 | ## Documentation 71 | 72 | - [CLI Integration Guide](https://unlighthouse.dev/integrations/cli.html) 73 | - [CI Integration Guide](https://unlighthouse.dev/integrations/ci.html) 74 | - [Configuration Reference](https://unlighthouse.dev/guide/config.html) 75 | 76 | ## License 77 | 78 | MIT License © 2021-PRESENT [Harlan Wilton](https://github.com/harlan-zw) 79 | -------------------------------------------------------------------------------- /docs/1.guide/guides/dynamic-sampling.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: "Dynamic Sampling" 3 | description: "Automatically sample similar pages to reduce scan time for sites with many similar URLs like blogs or e-commerce." 4 | navigation: 5 | title: "Dynamic Sampling" 6 | --- 7 | 8 | ## Introduction 9 | 10 | Dynamic sampling intelligently groups similar pages and scans only a representative sample. This feature prevents performance issues when scanning sites with hundreds of similar pages like blogs, product catalogs, or user profiles. 11 | 12 | Dynamic sampling is enabled by default with 5 samples per group. 13 | 14 | ## How it works 15 | 16 | When dynamic sampling is enabled, it will group paths into chunks based on their path tree. 17 | 18 | For example, let's imagine we have a blog on our site and there are hundreds of blog posts. Scanning every blog post will 19 | take a long time and may even break Unlighthouse. 20 | 21 | The path structure is `/blog/{post}`. 22 | 23 | Unlighthouse will turn this path structure into groups based on the `/blog` prefix. By default, it will sample 24 | 5 paths starting with this prefix. 25 | 26 | A sample being a random selection of paths within this group. 27 | 28 | For example if we have the posts: 29 | 30 | - `/blog/post-a` 31 | - `/blog/post-b` 32 | - `/blog/post-c` 33 | - `/blog/post-d` 34 | - `/blog/post-e` 35 | - `/blog/post-f` 36 | - `/blog/post-g` 37 | - `/blog/post-h` 38 | - `/blog/post-i` 39 | 40 | After sampling, we may end up with the random selection: 41 | 42 | - `/blog/post-c` 43 | - `/blog/post-d` 44 | - `/blog/post-e` 45 | - `/blog/post-h` 46 | - `/blog/post-i` 47 | 48 | ## Usage 49 | 50 | It is configured using the `scanner.dynamicSampling` option. 51 | 52 | ```ts 53 | import { defineUnlighthouseConfig } from 'unlighthouse/config' 54 | 55 | export default defineUnlighthouseConfig({ 56 | scanner: { 57 | dynamicSampling: 10, // Number of samples per group (default: 5) 58 | }, 59 | }) 60 | ``` 61 | 62 | ### Disable Dynamic Sampling 63 | 64 | ```ts 65 | export default defineUnlighthouseConfig({ 66 | scanner: { 67 | dynamicSampling: false, 68 | }, 69 | }) 70 | ``` 71 | 72 | Alternatively, you can disable it using the CLI `--disable-dynamic-sampling`. 73 | -------------------------------------------------------------------------------- /packages/cli/src/reporters/csvExpanded.ts: -------------------------------------------------------------------------------- 1 | import type { UnlighthouseRouteReport } from '../types' 2 | import type { ReporterConfig } from './types' 3 | import { get } from 'lodash-es' 4 | import { csvSimpleFormat } from './csvSimple' 5 | 6 | export function reportCSVExpanded(reports: UnlighthouseRouteReport[], { columns }: ReporterConfig): string { 7 | const { headers, body } = csvSimpleFormat(reports) 8 | for (const k of Object.keys(columns)) { 9 | // already have overview 10 | if (k === 'overview') 11 | continue 12 | // check if k is within the reports 13 | if (!reports[0].report.categories.find(category => category.key === k)) 14 | continue 15 | 16 | // add to headers 17 | headers.push( 18 | ...columns[k] 19 | .map(column => ({ 20 | column, 21 | val: get(reports[0], column.key), 22 | })) 23 | .filter(({ val }) => val?.scoreDisplayMode && val.scoreDisplayMode !== 'informative' && val.scoreDisplayMode !== 'notApplicable') 24 | .map(({ column }) => column.label), 25 | ) 26 | } 27 | 28 | reports.forEach(({ report }, i) => { 29 | for (const k of Object.keys(columns)) { 30 | // already have overview 31 | if (k === 'overview') 32 | continue 33 | // check if k is within the reports 34 | if (!reports[0].report.categories.find(category => category.key === k)) 35 | continue 36 | 37 | // headers are good, now add body 38 | body[i].push( 39 | ...columns[k] 40 | .map(column => get(report, column.key.replace('report.', ''))) 41 | .filter(val => val?.scoreDisplayMode && val.scoreDisplayMode !== 'informative' && val.scoreDisplayMode !== 'notApplicable') 42 | .map((val) => { 43 | if (val.scoreDisplayMode === 'binary') 44 | return val.score 45 | if (val.scoreDisplayMode === 'numeric') 46 | // round to 2 decimal places 47 | return Math.round(val.numericValue * 100) / 100 48 | return val.score 49 | }), 50 | ) 51 | } 52 | }) 53 | 54 | return [ 55 | headers.join(','), 56 | ...body.map(row => row.join(',')), 57 | ] 58 | .flat() 59 | .join('\n') 60 | } 61 | -------------------------------------------------------------------------------- /docs/1.guide/guides/puppeteer.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: "Puppeteer Configuration" 3 | description: "Customize Puppeteer browser settings for Unlighthouse scanning including headless mode and navigation hooks." 4 | navigation: 5 | title: "Puppeteer" 6 | --- 7 | 8 | Unlighthouse uses [puppeteer](https://github.com/puppeteer/puppeteer) to run the lighthouse module. 9 | 10 | ### Puppeteer configuration 11 | 12 | You can configure puppeteer with the `puppeteerOptions` key, which will be passed to the puppeteer launch constructor. 13 | 14 | See [puppeteer-launch-options](https://pptr.dev/#?product=Puppeteer&version=v13.0.1&show=api-puppeteerlaunchoptions) for more information. 15 | 16 | For example, you could run without a headless browser. Although not recommended. 17 | 18 | ```ts 19 | import { defineUnlighthouseConfig } from 'unlighthouse/config' 20 | 21 | export default defineUnlighthouseConfig({ 22 | puppeteerOptions: { 23 | headless: false, 24 | }, 25 | }) 26 | ``` 27 | 28 | ## Hook into puppeteer navigation 29 | 30 | There may be instances where you need to hook into how the puppeteer instance is handling your pages. 31 | 32 | A hook is provided to do this. 33 | 34 | ```ts 35 | let token 36 | 37 | export default defineUnlighthouseConfig({ 38 | hooks: { 39 | 'puppeteer:before-goto': async (page) => { 40 | if (!token) 41 | token = await generateToken() 42 | 43 | // set authentication token when we load a new page 44 | await page.evaluateOnNewDocument((token) => { 45 | localStorage.clear() 46 | localStorage.setItem('token', token) 47 | }, token) 48 | }, 49 | }, 50 | }) 51 | ``` 52 | 53 | **Delete an element** 54 | 55 | ```ts 56 | export default defineUnlighthouseConfig({ 57 | hooks: { 58 | 'puppeteer:before-goto': async (page) => { 59 | const deleteSelector = '.VPNav' 60 | page.waitForNavigation().then(async () => { 61 | await page.waitForTimeout(1000) 62 | await page.evaluate((sel) => { 63 | const elements = document.querySelectorAll(sel) 64 | for (let i = 0; i < elements.length; i++) 65 | elements[i].parentNode.removeChild(elements[i]) 66 | }, deleteSelector) 67 | }) 68 | }, 69 | }, 70 | }) 71 | ``` 72 | -------------------------------------------------------------------------------- /packages/core/src/discovery/sitemap.ts: -------------------------------------------------------------------------------- 1 | import Sitemapper from 'sitemapper' 2 | import { $URL, withBase } from 'ufo' 3 | import { useLogger } from '../logger' 4 | import { isScanOrigin } from '../router' 5 | import { useUnlighthouse } from '../unlighthouse' 6 | import { fetchUrlRaw } from '../util' 7 | 8 | function validSitemapEntry(url: string) { 9 | return url && (url.startsWith('http') || url.startsWith('/')) 10 | } 11 | 12 | /** 13 | * Fetches routes from a sitemap file. 14 | */ 15 | export async function extractSitemapRoutes(site: string, sitemaps: true | (string[])) { 16 | // make sure we're working from the host name 17 | site = new $URL(site).origin 18 | const unlighthouse = useUnlighthouse() 19 | const logger = useLogger() 20 | if (sitemaps === true || sitemaps.length === 0) 21 | sitemaps = [`${site}/sitemap.xml`] 22 | const sitemap = new Sitemapper({ 23 | timeout: 15000, // 15 seconds 24 | debug: unlighthouse.resolvedConfig.debug, 25 | }) 26 | let paths: string[] = [] 27 | for (let sitemapUrl of new Set(sitemaps)) { 28 | logger.debug(`Attempting to fetch sitemap at ${sitemapUrl}`) 29 | // make sure it's absolute 30 | if (!sitemapUrl.startsWith('http')) 31 | sitemapUrl = withBase(sitemapUrl, site) 32 | // sitemapper does not support txt sitemaps 33 | if (sitemapUrl.endsWith('.txt')) { 34 | const sitemapTxt = await fetchUrlRaw( 35 | sitemapUrl, 36 | unlighthouse.resolvedConfig, 37 | ) 38 | if (sitemapTxt.valid) { 39 | const sites = (sitemapTxt.response!.data as string).trim().split('\n').filter(validSitemapEntry) 40 | if (sites?.length) 41 | paths = [...paths, ...sites] 42 | 43 | logger.debug(`Fetched ${sitemapUrl} with ${sites.length} URLs.`) 44 | } 45 | } 46 | else { 47 | const { sites } = await sitemap.fetch(sitemapUrl) 48 | if (sites?.length) 49 | paths = [...paths, ...sites] 50 | logger.debug(`Fetched ${sitemapUrl} with ${sites?.length || '0'} URLs.`) 51 | } 52 | } 53 | const filtered = paths.filter(isScanOrigin) 54 | // for the paths we need to validate that they will be scanned 55 | return { paths: filtered, ignored: paths.length - filtered.length, sitemaps } 56 | } 57 | -------------------------------------------------------------------------------- /packages/core/src/util/filter.ts: -------------------------------------------------------------------------------- 1 | import { createRouter, toRouteMatcher } from 'radix3' 2 | 3 | interface CreateFilterOptions { 4 | include?: (string | RegExp)[] 5 | exclude?: (string | RegExp)[] 6 | } 7 | 8 | export function createFilter(options: CreateFilterOptions = {}): (path: string) => boolean { 9 | const include = options.include || [] 10 | const exclude = options.exclude || [] 11 | if (include.length === 0 && exclude.length === 0) 12 | return () => true 13 | 14 | return function (path: string): boolean { 15 | // check include first 16 | for (const v of [{ rules: include, result: true }, { rules: exclude, result: false }]) { 17 | const regexRules = v.rules.filter(r => r instanceof RegExp) as RegExp[] 18 | if (regexRules.some(r => r.test(path))) 19 | return v.result 20 | 21 | const stringRules = v.rules.filter(r => typeof r === 'string') as string[] 22 | if (stringRules.length > 0) { 23 | const routes = {} 24 | for (const r of stringRules) { 25 | // quick scan of literal string matches 26 | if (r === path) 27 | return v.result 28 | 29 | // need to flip the array data for radix3 format, true value is arbitrary 30 | // @ts-expect-error untyped 31 | routes[r] = true 32 | } 33 | const routeRulesMatcher = toRouteMatcher(createRouter({ routes, strictTrailingSlash: false })) 34 | if (routeRulesMatcher.matchAll(path).length > 0) 35 | return Boolean(v.result) 36 | } 37 | } 38 | return include.length === 0 39 | } 40 | } 41 | 42 | // types of file extensions that would return a HTML mime type 43 | const HTML_EXPLICIT_EXTENSIONS = [ 44 | // html 45 | '.html', 46 | '.htm', 47 | // php 48 | '.php', 49 | // asp 50 | '.asp', 51 | '.aspx', 52 | ] 53 | const FILE_MATCH_REGEX = /\.([0-9a-z])+$/i 54 | 55 | export function isImplicitOrExplicitHtml(path: string): boolean { 56 | const lastPathSegment = path.split('/').pop() || path 57 | // if it ends with a slash, then we assume it's a index HTML 58 | if (lastPathSegment.endsWith('/')) 59 | return true // implicit 60 | const extension = lastPathSegment?.match(FILE_MATCH_REGEX)?.[0] 61 | return !extension || HTML_EXPLICIT_EXTENSIONS.includes(extension) 62 | } 63 | -------------------------------------------------------------------------------- /packages/unlighthouse-ci/README.md: -------------------------------------------------------------------------------- 1 | # unlighthouse-ci 2 | 3 | Dedicated CI package for [Unlighthouse](https://github.com/harlan-zw/unlighthouse) that provides continuous integration capabilities with budget enforcement and exit codes. 4 | 5 | ## Installation 6 | 7 | ```bash 8 | # Use directly with npx 9 | npx unlighthouse-ci --site https://example.com 10 | 11 | # Or install globally 12 | npm install -g unlighthouse-ci 13 | ``` 14 | 15 | ## Usage 16 | 17 | ### Basic CI Scanning 18 | 19 | ```bash 20 | # Run CI scan with default budget (75) 21 | unlighthouse-ci --site https://example.com 22 | 23 | # Custom performance budget 24 | unlighthouse-ci --site https://example.com --budget 85 25 | 26 | # Desktop scanning with custom output 27 | unlighthouse-ci --site https://example.com --desktop --output-path ./lighthouse-reports 28 | ``` 29 | 30 | ### GitHub Actions Integration 31 | 32 | ```yaml 33 | name: Lighthouse CI 34 | on: [push, pull_request] 35 | jobs: 36 | lighthouse: 37 | runs-on: ubuntu-latest 38 | steps: 39 | - uses: actions/checkout@v2 40 | - name: Run Lighthouse CI 41 | run: npx unlighthouse-ci --site ${{ secrets.SITE_URL }} --budget 80 42 | ``` 43 | 44 | ### Features 45 | 46 | - Performance budget enforcement with configurable thresholds 47 | - Exit codes for CI/CD pipeline integration 48 | - Static report generation for hosting 49 | - Comprehensive logging and debugging 50 | - Support for custom Lighthouse configurations 51 | 52 | ## Configuration 53 | 54 | Create `unlighthouse.config.ts` for advanced CI configuration: 55 | 56 | ```ts 57 | import { defineUnlighthouseConfig } from 'unlighthouse/config' 58 | 59 | export default defineUnlighthouseConfig({ 60 | site: 'https://example.com', 61 | ci: { 62 | budget: 80, 63 | buildStatic: true, 64 | }, 65 | lighthouseOptions: { 66 | onlyCategories: ['performance', 'accessibility', 'seo'], 67 | } 68 | }) 69 | ``` 70 | 71 | ## Documentation 72 | 73 | - [CI Integration Guide](https://unlighthouse.dev/integrations/ci.html) 74 | - [GitHub Actions Example](https://unlighthouse.dev/integrations/ci.html#github-actions) 75 | - [Configuration Reference](https://unlighthouse.dev/guide/config.html) 76 | 77 | ## License 78 | 79 | MIT License © 2021-PRESENT [Harlan Wilton](https://github.com/harlan-zw) 80 | -------------------------------------------------------------------------------- /packages/client/components/Cell/CellScoreSingle.vue: -------------------------------------------------------------------------------- 1 | 27 | 28 | 56 | -------------------------------------------------------------------------------- /packages/client/components/Cell/CellWebVitals.vue: -------------------------------------------------------------------------------- 1 | 9 | 10 | 52 | -------------------------------------------------------------------------------- /packages/client/components/Results/ResultsTableHead.vue: -------------------------------------------------------------------------------- 1 | 21 | 22 | 54 | -------------------------------------------------------------------------------- /packages/cli/src/reporters/lighthouseServer.ts: -------------------------------------------------------------------------------- 1 | import type { UnlighthouseRouteReport } from '../types' 2 | import type { ReporterConfig } from './types' 3 | import ApiClient from '@lhci/utils/src/api-client.js' 4 | import { 5 | getAncestorHash, 6 | getAuthor, 7 | getAvatarUrl, 8 | getCommitMessage, 9 | getCommitTime, 10 | getCurrentBranch, 11 | getCurrentHash, 12 | getExternalBuildUrl, 13 | } from '@lhci/utils/src/build-context.js' 14 | import fs from 'fs-extra' 15 | import { handleError } from '../errors' 16 | 17 | export async function reportLighthouseServer( 18 | reports: UnlighthouseRouteReport[], 19 | { lhciBuildToken, lhciHost, lhciAuth }: ReporterConfig, 20 | ): Promise { 21 | try { 22 | const api = new ApiClient({ 23 | fetch, 24 | rootURL: lhciHost, 25 | basicAuth: (typeof lhciAuth === 'string' && lhciAuth.includes(':')) 26 | ? { username: lhciAuth.split(':')[0], password: lhciAuth.split(':')[1] } 27 | : undefined, 28 | }) 29 | api.setBuildToken(lhciBuildToken) 30 | const project = await api.findProjectByToken(lhciBuildToken) 31 | const baseBranch = project.baseBranch || 'master' 32 | const hash = getCurrentHash() 33 | const branch = getCurrentBranch() 34 | const ancestorHash = getAncestorHash('HEAD', baseBranch) 35 | const build = await api.createBuild({ 36 | projectId: project.id, 37 | lifecycle: 'unsealed', 38 | hash, 39 | branch, 40 | ancestorHash, 41 | commitMessage: getCommitMessage(hash), 42 | author: getAuthor(hash), 43 | avatarUrl: getAvatarUrl(hash), 44 | externalBuildUrl: getExternalBuildUrl(), 45 | runAt: new Date().toISOString(), 46 | committedAt: getCommitTime(hash), 47 | ancestorCommittedAt: ancestorHash 48 | ? getCommitTime(ancestorHash) 49 | : undefined, 50 | }) 51 | 52 | for (const report of reports) { 53 | const lighthouseResult = await fs.readJson( 54 | `${report.artifactPath}/lighthouse.json`, 55 | ) 56 | 57 | await api.createRun({ 58 | projectId: project.id, 59 | buildId: build.id, 60 | representative: false, 61 | url: `${report.route.url}${report.route.path}`, 62 | lhr: JSON.stringify(lighthouseResult), 63 | }) 64 | } 65 | await api.sealBuild(build.projectId, build.id) 66 | } 67 | catch (e) { 68 | handleError(e) 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /packages/cli/test/csv-reports.test.ts: -------------------------------------------------------------------------------- 1 | import type { UnlighthouseRouteReport } from '../src/types' 2 | import { describe, expect, it } from 'vitest' 3 | import { DefaultColumns } from '../../core/src/constants' 4 | import { generateReportPayload } from '../src/reporters' 5 | import _lighthouseReport from './__fixtures__/lighthouseReport.mjs' 6 | 7 | const lighthouseReport = _lighthouseReport as any as UnlighthouseRouteReport[] 8 | 9 | describe('csv reports', () => { 10 | it('basic', () => { 11 | const actual = generateReportPayload('csv', lighthouseReport) 12 | expect(actual).toMatchInlineSnapshot(` 13 | "URL,Score,Performance,Accessibility,Best Practices,SEO 14 | "/",98,100,100,100,92 15 | "/blog",97,100,97,100,92 16 | "/blog/2023-february",97,100,97,100,92 17 | "/blog/2023-march",97,100,97,100,92 18 | "/blog/how-the-heck-does-vite-work",97,100,97,100,92 19 | "/blog/modern-package-development",95,100,97,92,92 20 | "/blog/vue-automatic-component-imports",97,100,97,100,92 21 | "/projects",97,100,97,100,92 22 | "/sponsors",97,100,97,100,92 23 | "/talks",97,100,97,100,92" 24 | `) 25 | }) 26 | 27 | it('expanded', () => { 28 | const actual = generateReportPayload('csvExpanded', lighthouseReport, { columns: DefaultColumns }) 29 | expect(actual).toMatchInlineSnapshot(` 30 | "URL,Score,Performance,Accessibility,Best Practices,SEO,FCP,LCP,CLS,FID,TBT,Color Contrast,Headings,Image Alts,Link Names,Errors,Inspector Issues,Images Responsive,Image Aspect Ratio,Indexable 31 | "/",98,100,100,100,92,140.98,279.17,0,68.82,0,1,1,1,1,1,1,1,1,1 32 | "/blog",97,100,97,100,92,149.49,271.21,0,51.46,0,0,1,1,1,1,1,1,1,1 33 | "/blog/2023-february",97,100,97,100,92,210.35,350.38,0,61.1,0,0,1,1,1,1,1,1,1,1 34 | "/blog/2023-march",97,100,97,100,92,392.09,486.98,0,44.92,0,0,1,1,1,1,1,1,1,1 35 | "/blog/how-the-heck-does-vite-work",97,100,97,100,92,205.31,332.11,0,52.51,2.51,0,1,1,1,1,1,1,1,1 36 | "/blog/modern-package-development",95,100,97,92,92,422.26,568.43,0,55.73,5.73,0,1,1,1,1,1,1,1,1 37 | "/blog/vue-automatic-component-imports",97,100,97,100,92,225.64,507.04,0,91.53,81.86,0,1,1,1,1,1,1,1,1 38 | "/projects",97,100,97,100,92,236.85,391.93,0,75.74,25.74,0,1,1,1,1,1,1,1,1 39 | "/sponsors",97,100,97,100,92,226.68,405.03,0,58.72,0,0,1,1,1,1,1,1,1,1 40 | "/talks",97,100,97,100,92,224.75,244.35,0,33.67,0,0,1,1,1,1,1,1,1,1" 41 | `) 42 | }) 43 | }) 44 | -------------------------------------------------------------------------------- /docs/2.integrations/webpack.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: "Webpack Integration" 3 | icon: i-logos-webpack 4 | description: "Add Lighthouse auditing to webpack-based projects with development server integration and HMR support." 5 | navigation: 6 | title: "Webpack" 7 | deprecated: true 8 | --- 9 | 10 | ::warning 11 | **Deprecated**: This integration will be removed in v1.0. We recommend using the [CLI](/integrations/cli) or [CI](/integrations/ci) integrations instead. [Learn more →](/integration-deprecations) 12 | :: 13 | 14 | ## Introduction 15 | 16 | The webpack integration provides Lighthouse auditing capabilities for webpack-based applications during development. 17 | 18 | ## Install 19 | 20 | ::code-group 21 | 22 | ```bash [yarn] 23 | yarn add -D @unlighthouse/webpack 24 | ``` 25 | 26 | ```bash [npm] 27 | npm install -D @unlighthouse/webpack 28 | ``` 29 | 30 | ```bash [pnpm] 31 | pnpm add -D @unlighthouse/webpack 32 | ``` 33 | 34 | :: 35 | 36 | ### Git ignore reports 37 | 38 | Unlighthouse will save your reports in `outputDir` (`.unlighthouse` by default), 39 | it's recommended you .gitignore these files. 40 | 41 | ``` 42 | .unlighthouse 43 | ``` 44 | 45 | ## Usage 46 | 47 | To begin using Unlighthouse, you'll need to add extend your webpack configuration. 48 | 49 | When you run your webpack app, it will give you the URL of client, only once you visit the client will Unlighthouse 50 | start. 51 | 52 | ### webpack.config.js example 53 | 54 | ```js webpack.config.js 55 | import Unlighthouse from '@unlighthouse/webpack' 56 | 57 | export default { 58 | // ... 59 | plugins: [ 60 | Unlighthouse({ 61 | // config 62 | }), 63 | ], 64 | } 65 | ``` 66 | 67 | ### CJS example 68 | 69 | ```js webpack.config.js 70 | const Unlighthouse = require('@unlighthouse/webpack') 71 | 72 | export default { 73 | // ... 74 | plugins: [ 75 | Unlighthouse({ 76 | // config 77 | }), 78 | ], 79 | } 80 | ``` 81 | 82 | ## Configuration 83 | 84 | You can either configure Unlighthouse via the plugin, or you can provide a [config file](/guide/guides/config) 85 | in the root directory. 86 | 87 | ### Example 88 | 89 | ```js webpack.config.ts 90 | import Unlighthouse from '@unlighthouse/webpack' 91 | 92 | export default { 93 | // ... 94 | plugins: [ 95 | Unlighthouse({ 96 | scanner: { 97 | // simulate a desktop device 98 | device: 'desktop', 99 | } 100 | }), 101 | ], 102 | } 103 | ``` 104 | -------------------------------------------------------------------------------- /docs/2.integrations/3.nuxt.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: "Nuxt Integration" 3 | icon: i-logos-nuxt-icon 4 | description: "Integrate Lighthouse audits directly into your Nuxt development workflow with automatic route discovery." 5 | navigation: 6 | title: "Nuxt" 7 | deprecated: true 8 | --- 9 | 10 | ::warning 11 | **Deprecated**: This integration will be removed in v1.0. We recommend using the [CLI](/integrations/cli) or [CI](/integrations/ci) integrations instead. [Learn more →](/integration-deprecations) 12 | :: 13 | 14 | ## Introduction 15 | 16 | The Nuxt integration provides seamless Lighthouse auditing during development with automatic route discovery and hot module reloading support. 17 | 18 | ## Install 19 | 20 | ::code-group 21 | 22 | ```bash [yarn] 23 | yarn add -D @unlighthouse/nuxt 24 | ``` 25 | 26 | ```bash [npm] 27 | npm install -D @unlighthouse/nuxt 28 | ``` 29 | 30 | ```bash [pnpm] 31 | pnpm add -D @unlighthouse/nuxt 32 | ``` 33 | 34 | :: 35 | 36 | ### Git ignore reports 37 | 38 | Unlighthouse will save your reports in `outputDir` (`.unlighthouse` by default), 39 | it's recommended you .gitignore these files. 40 | 41 | ``` 42 | .unlighthouse 43 | ``` 44 | 45 | ## Usage 46 | 47 | To begin using Unlighthouse, you'll need to add the module to `buildModules`. 48 | 49 | When you run your Nuxt app, it will give you the URL of client, only once you visit the client will Unlighthouse start. 50 | 51 | ### Nuxt 3 52 | 53 | ```js nuxt.config.ts 54 | import { defineNuxtConfig } from 'nuxt3' 55 | 56 | export default defineNuxtConfig({ 57 | modules: [ 58 | '@unlighthouse/nuxt', 59 | ], 60 | }) 61 | ``` 62 | 63 | ### Nuxt 2 64 | 65 | ```js nuxt.config.js 66 | export default { 67 | buildModules: [ 68 | '@unlighthouse/nuxt', 69 | ], 70 | } 71 | ``` 72 | 73 | Type support can be added by adding the `@unlighthouse/nuxt` module to your `plugins`. Nuxt v3 will automatically add type support. 74 | 75 | ```json tsconfig.json 76 | { 77 | "compilerOptions": { 78 | "types": [ 79 | "@unlighthouse/nuxt" 80 | ] 81 | } 82 | } 83 | ``` 84 | 85 | ## Configuration 86 | 87 | You can either configure Unlighthouse via the `unlighthouse` key in your Nuxt config, or you can provide a `unlighthouse.config.ts` file 88 | in the root directory. 89 | 90 | ### Example 91 | 92 | ```js nuxt.config.js 93 | export default { 94 | unlighthouse: { 95 | scanner: { 96 | // simulate a desktop device 97 | device: 'desktop', 98 | } 99 | } 100 | } 101 | ``` 102 | -------------------------------------------------------------------------------- /packages/client/components/Cell/CellNetworkRequests.vue: -------------------------------------------------------------------------------- 1 | 35 | 36 | 59 | -------------------------------------------------------------------------------- /packages/core/src/puppeteer/util.ts: -------------------------------------------------------------------------------- 1 | import type { Page } from '../types/puppeteer' 2 | import { useLogger, useUnlighthouse } from '../unlighthouse' 3 | 4 | export async function setupPage(page: Page) { 5 | const { resolvedConfig, hooks } = useUnlighthouse() 6 | const logger = useLogger() 7 | const softErrorHandler = (ctx: string) => (err: Error) => { 8 | logger.error(ctx, err) 9 | } 10 | const browser = page.browser() 11 | // ignore csp errors 12 | await page.setBypassCSP(true) 13 | 14 | if (resolvedConfig.auth) 15 | await page.authenticate(resolvedConfig.auth).catch(softErrorHandler('Failed to authenticate')) 16 | 17 | // set local storage 18 | if (resolvedConfig.localStorage) { 19 | await page.evaluateOnNewDocument( 20 | (data) => { 21 | localStorage.clear() 22 | for (const key in data) 23 | localStorage.setItem(key, data[key]) 24 | }, 25 | resolvedConfig.localStorage, 26 | ) 27 | } 28 | // set session storage 29 | if (resolvedConfig.sessionStorage) { 30 | await page.evaluateOnNewDocument( 31 | (data) => { 32 | sessionStorage.clear() 33 | for (const key in data) 34 | sessionStorage.setItem(key, data[key]) 35 | }, 36 | resolvedConfig.sessionStorage, 37 | ) 38 | } 39 | if (resolvedConfig.cookies) { 40 | await page.setCookie(...resolvedConfig.cookies.map(cookie => ({ domain: resolvedConfig.site, ...cookie }))) 41 | .catch(softErrorHandler('Failed to set cookies')) 42 | } 43 | if (resolvedConfig.extraHeaders) { 44 | await page.setExtraHTTPHeaders(resolvedConfig.extraHeaders) 45 | .catch(softErrorHandler('Failed to set extra headers')) 46 | } 47 | 48 | // Wait for Lighthouse to open url, then allow hook to run 49 | browser.on('targetchanged', async (target) => { 50 | const page = await target.page() 51 | if (page) { 52 | // in case they get reset 53 | if (resolvedConfig.cookies) { 54 | await page.setCookie(...resolvedConfig.cookies.map(cookie => ({ domain: resolvedConfig.site, ...cookie }))) 55 | .catch(softErrorHandler('Failed to set cookies')) 56 | } 57 | // set local storage 58 | if (resolvedConfig.extraHeaders) { 59 | await page.setExtraHTTPHeaders(resolvedConfig.extraHeaders) 60 | .catch(softErrorHandler('Failed to set extra headers')) 61 | } 62 | if (resolvedConfig.userAgent) { 63 | await page.setUserAgent(resolvedConfig.userAgent) 64 | } 65 | await hooks.callHook('puppeteer:before-goto', page) 66 | } 67 | }) 68 | } 69 | -------------------------------------------------------------------------------- /packages/unlighthouse/README.md: -------------------------------------------------------------------------------- 1 | # unlighthouse 2 | 3 | The main package for [Unlighthouse](https://github.com/harlan-zw/unlighthouse) - scan your entire website with Google Lighthouse. This is a convenience package that includes the core functionality and CLI tools. 4 | 5 | ## Quick Start 6 | 7 | ```bash 8 | # Scan your website instantly 9 | npx unlighthouse --site https://example.com 10 | 11 | # CI mode with performance budgets 12 | npx unlighthouse-ci --site https://example.com --budget 80 13 | ``` 14 | 15 | ## What's Included 16 | 17 | This package includes: 18 | - `@unlighthouse/core` - Core scanning engine 19 | - `@unlighthouse/cli` - Command-line interface 20 | - `@unlighthouse/client` - Web interface for results 21 | - Two binaries: `unlighthouse` and `unlighthouse-ci` 22 | 23 | ## Installation 24 | 25 | ```bash 26 | # Global installation 27 | npm install -g unlighthouse 28 | 29 | # Project dependency 30 | npm install unlighthouse --save-dev 31 | ``` 32 | 33 | ## Usage 34 | 35 | ### Interactive CLI 36 | 37 | ```bash 38 | # Basic scan 39 | unlighthouse --site https://example.com 40 | 41 | # With debugging and custom device 42 | unlighthouse --site https://example.com --debug --desktop 43 | 44 | # Custom configuration 45 | unlighthouse --config-file unlighthouse.config.ts 46 | ``` 47 | 48 | ### Programmatic Usage 49 | 50 | ```ts 51 | import { createUnlighthouse } from 'unlighthouse' 52 | 53 | const unlighthouse = await createUnlighthouse({ 54 | site: 'https://example.com', 55 | debug: true 56 | }) 57 | 58 | await unlighthouse.start() 59 | ``` 60 | 61 | ### CI Integration 62 | 63 | ```bash 64 | # Enforce performance budgets in CI 65 | unlighthouse-ci --site https://example.com --budget 85 66 | ``` 67 | 68 | ## Configuration 69 | 70 | Create `unlighthouse.config.ts`: 71 | 72 | ```ts 73 | import { defineUnlighthouseConfig } from 'unlighthouse/config' 74 | 75 | export default defineUnlighthouseConfig({ 76 | site: 'https://example.com', 77 | scanner: { 78 | device: 'desktop', 79 | throttle: false, 80 | }, 81 | lighthouseOptions: { 82 | onlyCategories: ['performance', 'accessibility'], 83 | } 84 | }) 85 | ``` 86 | 87 | ## Documentation 88 | 89 | - [Getting Started Guide](https://unlighthouse.dev/guide/) 90 | - [CLI Integration](https://unlighthouse.dev/integrations/cli.html) 91 | - [CI Integration](https://unlighthouse.dev/integrations/ci.html) 92 | - [API Reference](https://unlighthouse.dev/api/) 93 | - [Configuration Reference](https://unlighthouse.dev/guide/config.html) 94 | 95 | ## License 96 | 97 | MIT License © 2021-PRESENT [Harlan Wilton](https://github.com/harlan-zw) 98 | -------------------------------------------------------------------------------- /packages/client/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@unlighthouse/client", 3 | "type": "module", 4 | "version": "0.17.4", 5 | "description": "UI Client for Unlighthouse.", 6 | "license": "MIT", 7 | "homepage": "https://github.com/harlan-zw/unlighthouse#readme", 8 | "repository": { 9 | "type": "git", 10 | "url": "git+https://github.com/harlan-zw/unlighthouse.git", 11 | "directory": "packages/client" 12 | }, 13 | "bugs": { 14 | "url": "https://github.com/harlan-zw/unlighthouse/issues" 15 | }, 16 | "keywords": [ 17 | "lighthouse", 18 | "audit", 19 | "seo", 20 | "performance", 21 | "ui", 22 | "client", 23 | "unlighthouse" 24 | ], 25 | "main": "dist/index.html", 26 | "files": [ 27 | "*.d.ts", 28 | "dist" 29 | ], 30 | "scripts": { 31 | "stub": "pnpm run build", 32 | "dev": "vite .", 33 | "build:watch": "vite build --watch", 34 | "build": "vite build --mode production" 35 | }, 36 | "dependencies": { 37 | "@headlessui/vue": "catalog:dependencies", 38 | "@nuxt/ui": "catalog:dependencies", 39 | "@tailwindcss/vite": "catalog:dependencies", 40 | "dayjs": "catalog:dependencies", 41 | "defu": "catalog:dependencies", 42 | "fuse.js": "catalog:dependencies", 43 | "lightweight-charts": "catalog:dependencies", 44 | "lodash-es": "catalog:dependencies", 45 | "ufo": "catalog:dependencies", 46 | "vue": "catalog:dependencies" 47 | }, 48 | "devDependencies": { 49 | "@iconify-json/carbon": "catalog:dependencies", 50 | "@iconify-json/ic": "catalog:dependencies", 51 | "@iconify-json/icomoon-free": "catalog:dependencies", 52 | "@iconify-json/la": "catalog:dependencies", 53 | "@iconify-json/logos": "catalog:dependencies", 54 | "@iconify-json/mdi": "catalog:dependencies", 55 | "@iconify-json/simple-line-icons": "catalog:dependencies", 56 | "@iconify-json/vscode-icons": "catalog:dependencies", 57 | "@types/lodash": "catalog:", 58 | "@types/three": "catalog:", 59 | "@unlighthouse/core": "workspace:*", 60 | "@vitejs/plugin-vue": "catalog:", 61 | "@vue/compiler-sfc": "catalog:", 62 | "@vueuse/core": "catalog:dependencies", 63 | "@vueuse/router": "catalog:dependencies", 64 | "date-fns": "catalog:dependencies", 65 | "tailwindcss": "catalog:dependencies", 66 | "three": "catalog:dependencies", 67 | "typescript": "catalog:", 68 | "unplugin-auto-import": "catalog:dependencies", 69 | "unplugin-icons": "catalog:dependencies", 70 | "unplugin-vue-components": "catalog:dependencies", 71 | "vite": "catalog:dependencies", 72 | "vite-plugin-pages": "catalog:dependencies" 73 | } 74 | } 75 | -------------------------------------------------------------------------------- /packages/client/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 19 | Unlighthouse 20 | 24 | 25 | 65 | 66 | 67 |
68 | 78 | 79 | 80 | 81 | -------------------------------------------------------------------------------- /docs/1.guide/1.getting-started/1.integrations.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: "Integrations" 3 | description: "Integrate Unlighthouse into your existing build tools, frameworks, and CI/CD pipelines." 4 | navigation: 5 | title: "Integrations" 6 | --- 7 | 8 | ## Introduction 9 | 10 | Unlighthouse offers multiple integration options to fit seamlessly into your development workflow. Whether you're running manual scans, automating checks in CI/CD, or integrating with your build tools, Unlighthouse adapts to your needs. 11 | 12 | ## Command Line 13 | 14 | | Provider | Use Case | 15 | |--------------------------|-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| 16 | | [CLI](/integrations/cli) | Scan a production site such as [unlighthouse.dev](https://unlighthouse.dev).

You can manually provide a project mapping for [routes definitions](/guide/guides/route-definitions). | 17 | | [CI](/integrations/ci) | Run scans on sites based on automation events, i.e releasing and make [assertions on scores](/integrations/ci#assertions).

Can also be used to generate report sites such as [inspect.unlighthouse.dev](https://inspect.unlighthouse.dev/). | 18 | 19 | ## Build tools / Frameworks 20 | 21 | ::warning 22 | **Deprecation Notice**: Build tool integrations are deprecated and will be removed in v1.0. We recommend using the CLI or CI integrations instead. [Learn more →](/integration-deprecations) 23 | :: 24 | 25 | | Provider | Features | 26 | |---------------------------------------------------------------------------------------------------------------|-----------------------------------------------------------------------------------------------| 27 | | Nuxt.js |
  • Hot Module Reloading
  • Automatic Route Discovery
| 28 | | Vite |
  • Hot Module Reloading
  • Automatic Route Discovery
| 29 | | webpack |
  • Hot Module Reloading
| 30 | -------------------------------------------------------------------------------- /packages/cli/src/reporters/index.ts: -------------------------------------------------------------------------------- 1 | import type { ResolvedUserConfig, UnlighthouseRouteReport } from '@unlighthouse/core' 2 | import type { ReporterConfig, ReportJsonExpanded, ReportJsonSimple } from './types' 3 | import { join } from 'node:path' 4 | import fse from 'fs-extra' 5 | import { reportCSVExpanded } from './csvExpanded' 6 | import { reportCSVSimple } from './csvSimple' 7 | import { reportJsonExpanded } from './jsonExpanded' 8 | import { reportJsonSimple } from './jsonSimple' 9 | import { reportLighthouseServer } from './lighthouseServer' 10 | 11 | export function generateReportPayload(reporter: 'lighthouseServer', reports: UnlighthouseRouteReport[], config?: ReporterConfig): Promise 12 | export function generateReportPayload(reporter: 'jsonExpanded', reports: UnlighthouseRouteReport[]): ReportJsonExpanded 13 | export function generateReportPayload(reporter: 'jsonSimple' | 'json', reports: UnlighthouseRouteReport[]): ReportJsonSimple 14 | export function generateReportPayload(reporter: 'csvSimple' | 'csv', reports: UnlighthouseRouteReport[]): string 15 | export function generateReportPayload(reporter: 'csvExpanded', reports: UnlighthouseRouteReport[], config?: ReporterConfig): string 16 | export function generateReportPayload(reporter: string, _reports: UnlighthouseRouteReport[], config?: ReporterConfig): any { 17 | const reports = _reports 18 | .sort((a, b) => a.route.path.localeCompare(b.route.path)) 19 | .filter((r) => { 20 | if (!r.report?.categories) 21 | return false 22 | return r.report.audits 23 | }) 24 | 25 | if (reporter.startsWith('json')) { 26 | if (reporter === 'jsonSimple' || reporter === 'json') 27 | return reportJsonSimple(reports) 28 | if (reporter === 'jsonExpanded') 29 | return reportJsonExpanded(reports) 30 | } 31 | if (reporter.startsWith('csv')) { 32 | if (reporter === 'csvSimple' || reporter === 'csv') 33 | return reportCSVSimple(reports) 34 | if (reporter === 'csvExpanded') 35 | return reportCSVExpanded(reports, config) 36 | } 37 | if (reporter === 'lighthouseServer') 38 | return reportLighthouseServer(reports, config) 39 | 40 | throw new Error(`Unsupported reporter: ${reporter}.`) 41 | } 42 | 43 | export async function outputReport(reporter: string, config: Partial, payload: any) { 44 | if (reporter.startsWith('json')) { 45 | const path = join(config.outputPath, 'ci-result.json') 46 | await fse.writeJson(path, payload, { spaces: 2 }) 47 | return path 48 | } 49 | if (reporter.startsWith('csv')) { 50 | const path = join(config.outputPath, 'ci-result.csv') 51 | await fse.writeFile(path, payload) 52 | return path 53 | } 54 | throw new Error(`Unsupported reporter: ${reporter}.`) 55 | } 56 | -------------------------------------------------------------------------------- /test/cli.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, expect, it } from 'vitest' 2 | import createCli from '../packages/cli/src/createCli' 3 | import { pickOptions } from '../packages/cli/src/util' 4 | 5 | const argsv = (args: string[]) => ['node', 'unlighthouse.js', '--site', 'unlighthouse.dev', ...args] 6 | 7 | describe('cli args', () => { 8 | it('cache on', async () => { 9 | const cli = createCli() 10 | const { options } = cli.parse(argsv(['--cache'])) 11 | const picked = pickOptions(options) 12 | expect(picked.cache).toBeTruthy() 13 | }) 14 | it('cache off', async () => { 15 | const cli = createCli() 16 | const { options } = cli.parse(argsv(['--no-cache'])) 17 | const picked = pickOptions(options) 18 | expect(picked.cache).toBeFalsy() 19 | }) 20 | 21 | it('urls csv', async () => { 22 | const cli = createCli() 23 | const { options } = cli.parse(argsv(['--urls', '/my-path,/second-path', '--debug'])) 24 | expect(options.urls).toMatchInlineSnapshot('"/my-path,/second-path"') 25 | const picked = pickOptions(options) 26 | expect(picked.urls).toMatchInlineSnapshot(` 27 | [ 28 | "/my-path", 29 | "/second-path", 30 | ] 31 | `) 32 | }) 33 | 34 | it('cookies - single', async () => { 35 | const cli = createCli() 36 | const { options } = cli.parse(argsv(['--cookies', 'foo=bar'])) 37 | const picked = pickOptions(options) 38 | expect(picked.cookies).toMatchInlineSnapshot(` 39 | [ 40 | { 41 | "name": "foo", 42 | "value": "bar", 43 | }, 44 | ] 45 | `) 46 | }) 47 | 48 | it('cookies - multiple', async () => { 49 | const cli = createCli() 50 | const { options } = cli.parse(argsv(['--cookies', 'my-jwt-token=;my-other-cookie=value'])) 51 | const picked = pickOptions(options) 52 | expect(picked.cookies).toMatchInlineSnapshot(` 53 | [ 54 | { 55 | "name": "my-jwt-token", 56 | "value": "", 57 | }, 58 | { 59 | "name": "my-other-cookie", 60 | "value": "value", 61 | }, 62 | ] 63 | `) 64 | }) 65 | 66 | it ('extraHeaders - single', async () => { 67 | const cli = createCli() 68 | const { options } = cli.parse(argsv(['--extraHeaders', 'foo=bar'])) 69 | const picked = pickOptions(options) 70 | expect(picked.extraHeaders).toMatchInlineSnapshot(` 71 | { 72 | "foo": "bar", 73 | } 74 | `) 75 | }) 76 | 77 | it ('extraHeaders - multiple', async () => { 78 | const cli = createCli() 79 | const { options } = cli.parse(argsv(['--extraHeaders', 'foo=bar,my-other-header=value'])) 80 | const picked = pickOptions(options) 81 | expect(picked.extraHeaders).toMatchInlineSnapshot(` 82 | { 83 | "foo": "bar", 84 | "my-other-header": "value", 85 | } 86 | `) 87 | }) 88 | }) 89 | -------------------------------------------------------------------------------- /packages/core/src/discovery/routeDefinitions.ts: -------------------------------------------------------------------------------- 1 | import type { RouteDefinition } from '../types' 2 | import { join } from 'node:path' 3 | import { useLogger } from '../logger' 4 | import { useUnlighthouse } from '../unlighthouse' 5 | import { createRoutes } from '../util/createRoutes' 6 | 7 | /** 8 | * Using the configuration discovery details will try and resolve the route definitions using the file system. 9 | */ 10 | export async function discoverRouteDefinitions() { 11 | const { resolvedConfig } = useUnlighthouse() 12 | if (!resolvedConfig.discovery) 13 | return [] 14 | 15 | const logger = useLogger() 16 | 17 | const { supportedExtensions, pagesDir } = resolvedConfig.discovery 18 | 19 | // handle pages being in the root 20 | const dir = pagesDir === '' ? resolvedConfig.root.replace(`${resolvedConfig.root}/`, '') : pagesDir 21 | 22 | const resolveFiles = async (dir: string) => { 23 | const { globby } = (await import('globby')) 24 | 25 | // can't wrap single extension in {} within regex 26 | const extensions = supportedExtensions.length > 1 ? `{${supportedExtensions.join(',')}}` : supportedExtensions[0] 27 | 28 | return await globby([ 29 | join(dir, '**', `*.${extensions}`), 30 | '!**/README.md', 31 | '!**/node_modules', 32 | ], { 33 | cwd: resolvedConfig.root, 34 | // avoid some edge-cases 35 | deep: 5, 36 | // avoid scanning node_modules and any other expensive dirs 37 | gitignore: true, 38 | }) 39 | } 40 | 41 | const files: Record = {} 42 | const ext = new RegExp(`\\.(${supportedExtensions.join('|')})$`) 43 | const resolvedPages = await resolveFiles(dir) 44 | 45 | for (const page of resolvedPages) { 46 | const key = page.replace(ext, '') 47 | // .vue file takes precedence over other extensions 48 | if (/\.vue$/.test(page) || !files[key]) 49 | files[key] = page.replace(/(['"])/g, '\\$1') 50 | } 51 | 52 | logger.debug(`Discovered \`${resolvedPages.length}\` page files from \`${join(resolvedConfig.root, dir)}\`. Mapping to route definitions.`) 53 | if (resolvedPages.length) 54 | logger.debug(resolvedPages) 55 | 56 | return createRoutes({ 57 | files: Object.values(files), 58 | srcDir: resolvedConfig.root, 59 | pagesDir: dir, 60 | routeNameSplitter: '-', 61 | supportedExtensions, 62 | trailingSlash: false, 63 | }).map((route: RouteDefinition) => { 64 | // 65 | const pathNodes = route.path.split('/') 66 | route.path = pathNodes 67 | .map((n) => { 68 | // some fixes for next.js routing 69 | if (n.startsWith('[') && n.endsWith(']')) { 70 | const strippedNode = n 71 | .replace('[', '') 72 | .replace(']', '') 73 | .replace('...', '') 74 | return `:${strippedNode}` 75 | } 76 | return n 77 | }) 78 | .join('/') 79 | return route 80 | }) 81 | } 82 | -------------------------------------------------------------------------------- /packages/core/src/util/progressBox.ts: -------------------------------------------------------------------------------- 1 | import * as p from '@clack/prompts' 2 | 3 | export interface ProgressData { 4 | currentTask?: string 5 | completedTasks: number 6 | totalTasks: number 7 | averageScore?: number 8 | timeElapsed: number 9 | timeRemaining?: number 10 | } 11 | 12 | export interface ProgressBox { 13 | update: (progressData: ProgressData) => void 14 | clear: () => void 15 | } 16 | 17 | /** 18 | * Create a progress box that displays scanning progress using Clack spinner 19 | */ 20 | export function createProgressBox(): ProgressBox { 21 | let clackSpinner: ReturnType | undefined 22 | 23 | const update = (progressData: ProgressData) => { 24 | // Initialize Clack spinner if not started 25 | if (!clackSpinner && progressData.totalTasks > 0) { 26 | clackSpinner = p.spinner() 27 | clackSpinner.start('Starting scan...') 28 | } 29 | 30 | // Update progress 31 | if (clackSpinner && progressData.totalTasks > 0) { 32 | const percentage = Math.round((progressData.completedTasks / progressData.totalTasks) * 100) 33 | 34 | // Format additional info 35 | const formatTime = (ms: number) => { 36 | const minutes = Math.floor(ms / 60000) 37 | const seconds = Math.floor((ms % 60000) / 1000) 38 | if (minutes > 60) { 39 | const hours = Math.floor(minutes / 60) 40 | const remainingMins = minutes % 60 41 | return `${hours}h ${remainingMins}m` 42 | } 43 | return minutes > 0 ? `${minutes}m ${seconds}s` : `${seconds}s` 44 | } 45 | 46 | const formatScore = (score?: number) => { 47 | if (score === undefined) 48 | return 'calculating...' 49 | const rounded = Math.round(score * 100) 50 | return `${rounded}/100` 51 | } 52 | 53 | // Build progress message 54 | let message = `${percentage}% (${progressData.completedTasks}/${progressData.totalTasks})` 55 | 56 | if (progressData.averageScore !== undefined) { 57 | message += ` • Score: ${formatScore(progressData.averageScore)}` 58 | } 59 | 60 | message += ` • ${formatTime(progressData.timeElapsed)}` 61 | 62 | if (progressData.timeRemaining && progressData.timeRemaining > 0) { 63 | message += ` • ETA: ${formatTime(progressData.timeRemaining)}` 64 | } 65 | 66 | if (progressData.currentTask) { 67 | const currentTask = progressData.currentTask.length > 50 68 | ? `${progressData.currentTask.substring(0, 47)}...` 69 | : progressData.currentTask 70 | message += ` • ${currentTask}` 71 | } 72 | 73 | // Update the spinner message 74 | clackSpinner.message(message) 75 | } 76 | } 77 | 78 | const clear = () => { 79 | if (clackSpinner) { 80 | clackSpinner.stop('Scan completed!') 81 | clackSpinner = undefined 82 | } 83 | } 84 | 85 | return { update, clear } 86 | } 87 | -------------------------------------------------------------------------------- /docs/2.integrations/4.vite.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: "Vite Integration" 3 | icon: i-logos-vitejs 4 | description: "Integrate Lighthouse audits into your Vite development server with automatic route discovery and HMR support." 5 | navigation: 6 | title: "Vite" 7 | deprecated: true 8 | --- 9 | 10 | ::warning 11 | **Deprecated**: This integration will be removed in v1.0. We recommend using the [CLI](/integrations/cli) or [CI](/integrations/ci) integrations instead. [Learn more →](/integration-deprecations) 12 | :: 13 | 14 | ## Introduction 15 | 16 | The Vite integration enables Lighthouse auditing within your Vite development environment, providing real-time performance feedback as you build. 17 | 18 | ## Install 19 | 20 | ::code-group 21 | 22 | ```bash [yarn] 23 | yarn add -D @unlighthouse/vite 24 | ``` 25 | 26 | ```bash [npm] 27 | npm install -D @unlighthouse/vite 28 | ``` 29 | 30 | ```bash [pnpm] 31 | pnpm add -D @unlighthouse/vite 32 | ``` 33 | 34 | :: 35 | 36 | ### Git ignore reports 37 | 38 | Unlighthouse will save your reports in `outputDir` (`.unlighthouse` by default), 39 | it's recommended you .gitignore these files. 40 | 41 | ``` 42 | .unlighthouse 43 | ``` 44 | 45 | ## Usage 46 | 47 | To begin using Unlighthouse, you'll need to add the plugin to `plugins`. 48 | 49 | When you run your Vite app, it will give you the URL of client, only once you visit the client will Unlighthouse start. 50 | 51 | ```ts vite.config.ts 52 | import Unlighthouse from '@unlighthouse/vite' 53 | 54 | import { defineUnlighthouseConfig } from 'unlighthouse/config' 55 | 56 | export default defineUnlighthouseConfig({ 57 | plugins: [ 58 | Unlighthouse({ 59 | // config 60 | }) 61 | ] 62 | }) 63 | ``` 64 | 65 | ### Providing Route Definitions 66 | 67 | If you're using the [vite-plugin-pages](https://github.com/hannoeru/vite-plugin-pages) plugin, you can provide route definitions to Unlighthouse. 68 | 69 | You will need to hook into the plugin using the following code. 70 | 71 | ```ts vite.config.ts 72 | import { useUnlighthouse } from 'unlighthouse' 73 | 74 | export default defineUnlighthouseConfig({ 75 | plugins: [ 76 | Pages({ 77 | // ... 78 | onRoutesGenerated(routes) { 79 | // tell Unlighthouse about the routes 80 | const unlighthouse = useUnlighthouse() 81 | if (unlighthouse?.hooks) 82 | hooks.callHook('route-definitions-provided', routes) 83 | } 84 | }), 85 | ] 86 | }) 87 | ``` 88 | 89 | ## Configuration 90 | 91 | You can either configure Unlighthouse via the plugin, or you can provide a [config file](/guide/guides/config) file 92 | in the root directory. 93 | 94 | ### Example 95 | 96 | ```js vite.config.ts 97 | export default defineUnlighthouseConfig({ 98 | plugins: [ 99 | Unlighthouse({ 100 | scanner: { 101 | // simulate a desktop device 102 | device: 'desktop', 103 | } 104 | }) 105 | ] 106 | }) 107 | ``` 108 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ![unlighthouse - Scan your entire website with Google Lighthouse.](https://repository-images.githubusercontent.com/423079536/c88a81ee-43ec-40fc-a615-1d29bbeaaeb4) 2 | 3 |

Unlighthouse

4 | 5 | [![npm version][npm-version-src]][npm-version-href] 6 | [![npm downloads][npm-downloads-src]][npm-downloads-href] 7 | [![License][license-src]][license-href] 8 | 9 |

10 | Unlighthouse scans your entire site using Google Lighthouse,
with a modern UI, minimal config and smart sampling. 11 |

12 | 13 |

View Demo

14 | 15 |

16 | 17 | 18 | 21 | 22 |
19 | Made possible by my Sponsor Program 💖
Follow me @harlan_zw 🐦 • Join Discord for help

20 |
23 |

24 | 25 | ### Quick Setup 26 | 27 | Run the following command: 28 | 29 | ```bash 30 | npx unlighthouse --site 31 | # or PNPM 32 | pnpm dlx unlighthouse --site 33 | ``` 34 | 35 | _Requirements: Node >= 20.x._ 36 | 37 | ## Getting Started 38 | 39 | Install instructions for all integrations can be found on the [docs](https://unlighthouse.dev/) site. 40 | 41 | Need a hand? Join the [Discord](https://discord.gg/275MBUBvgP) for one-on-one help. 42 | 43 | #### gitignore 44 | 45 | Unlighthouse will save your reports in `outputDir`, 46 | it's recommended you .gitignore these files. 47 | 48 | ``` 49 | .unlighthouse 50 | ``` 51 | 52 | #### Debugging 53 | 54 | If you run into any issues with Unlighthouse, the first step should be to re-run the scan with debugging enabled. 55 | 56 | ```bash 57 | # NPM 58 | npx unlighthouse --site unlighthouse.dev --debug 59 | # or PNPM 60 | pnpm dlx unlighthouse --site unlighthouse.dev --debug 61 | ``` 62 | 63 | ## Docs 64 | 65 | Integration instructions, Guides, API and config spec can be found on [docs](https://unlighthouse.dev/) site. 66 | 67 | ## Sponsors 68 | 69 |

70 | 71 | 72 | 73 |

74 | 75 | ## License 76 | 77 | Licensed under the [MIT license](https://github.com/harlan-zw/unlighthouse/blob/main/LICENSE.md). 78 | 79 | 80 | [npm-version-src]: https://img.shields.io/npm/v/unlighthouse/latest.svg?style=flat&colorA=18181B&colorB=28CF8D 81 | [npm-version-href]: https://npmjs.com/package/unlighthouse 82 | 83 | [npm-downloads-src]: https://img.shields.io/npm/dm/unlighthouse.svg?style=flat&colorA=18181B&colorB=28CF8D 84 | [npm-downloads-href]: https://npmjs.com/package/unlighthouse 85 | 86 | [license-src]: https://img.shields.io/github/license/harlan-zw/unlighthouse.svg?style=flat&colorA=18181B&colorB=28CF8D 87 | [license-href]: https://github.com/harlan-zw/unlighthouse/blob/main/LICENSE.md 88 | -------------------------------------------------------------------------------- /packages/client/vite.config.ts: -------------------------------------------------------------------------------- 1 | import ui from '@nuxt/ui/vite' 2 | import Vue from '@vitejs/plugin-vue' 3 | import * as fs from 'fs-extra' 4 | import IconsResolver from 'unplugin-icons/resolver' 5 | import { HeadlessUiResolver } from 'unplugin-vue-components/resolvers' 6 | import { defineConfig } from 'vite' 7 | import { version } from '../../package.json' 8 | 9 | export default defineConfig(({ mode }) => ({ 10 | define: { 11 | __UNLIGHTHOUSE_VERSION__: JSON.stringify(version), 12 | }, 13 | plugins: [ 14 | Vue(), 15 | ui({ 16 | ui: { 17 | modal: { 18 | variants: { 19 | fullscreen: { 20 | true: { 21 | content: 'inset-0', 22 | }, 23 | false: { 24 | content: 'max-w-2xl', 25 | }, 26 | }, 27 | }, 28 | }, 29 | }, 30 | autoImport: { 31 | imports: [ 32 | 'vue', 33 | 'vue-router', 34 | '@vueuse/core', 35 | ], 36 | dts: true, 37 | vueTemplate: true, 38 | }, 39 | components: { 40 | dirs: ['components'], 41 | extensions: ['vue'], 42 | deep: true, 43 | resolvers: [ 44 | IconsResolver({ 45 | prefix: 'i', 46 | enabledCollections: ['carbon', 'ic', 'mdi', 'la', 'logos', 'vscode-icons', 'simple-line-icons', 'icomoon-free'], 47 | }), 48 | HeadlessUiResolver(), 49 | ], 50 | dts: true, 51 | directoryAsNamespace: false, 52 | collapseSamePrefixes: false, 53 | globalNamespaces: [], 54 | include: [/\.vue$/, /\.vue\?vue/], 55 | exclude: [/[\\/]node_modules[\\/]/, /[\\/]\.git[\\/]/, /[\\/]\.nuxt[\\/]/], 56 | }, 57 | }), 58 | // Icons({ 59 | // compiler: 'vue3', 60 | // autoInstall: true, 61 | // }), 62 | { 63 | name: 'unlighthouse-static-data-remover', 64 | async closeBundle() { 65 | if (mode === 'development') 66 | return 67 | 68 | const payloadPath = await this.resolve('./dist/assets/payload.js') 69 | if (payloadPath) 70 | await fs.remove(payloadPath.id) 71 | }, 72 | }, 73 | ], 74 | 75 | optimizeDeps: { 76 | include: [ 77 | 'vue', 78 | 'vue-router', 79 | '@vueuse/core', 80 | '@vueuse/router', 81 | 'lightweight-charts', 82 | 'lodash-es', 83 | 'dayjs', 84 | 'fuse.js', 85 | '@headlessui/vue', 86 | ], 87 | exclude: [ 88 | 'vue-demi', 89 | '@tailwindcss/oxide', 90 | ], 91 | }, 92 | 93 | build: { 94 | rollupOptions: { 95 | external: [ 96 | '@tailwindcss/oxide', 97 | '@tailwindcss/vite', 98 | /\.node$/, 99 | 'exsolve', 100 | 'pkg-types', 101 | 'confbox', 102 | 'pathe', 103 | /^@nuxt\/kit/, 104 | ], 105 | }, 106 | }, 107 | 108 | server: { 109 | fs: { 110 | strict: false, 111 | }, 112 | }, 113 | })) 114 | -------------------------------------------------------------------------------- /packages/cli/test/lighthouseServer-reports.test.ts: -------------------------------------------------------------------------------- 1 | import type { UnlighthouseRouteReport } from '../src/types' 2 | import ApiClient from '@lhci/utils/src/api-client.js' 3 | import fs from 'fs-extra' 4 | import { describe, expect, it, vi } from 'vitest' 5 | import { generateReportPayload } from '../src/reporters' 6 | import _lighthouseReport from './__fixtures__/lighthouseReport.mjs' 7 | 8 | const lighthouseReport = _lighthouseReport as any as UnlighthouseRouteReport[] 9 | 10 | vi.mock('fs-extra', () => { 11 | return { 12 | default: { 13 | readJson: vi.fn(() => new Promise(resolve => resolve({}))), 14 | }, 15 | } 16 | }) 17 | 18 | const setBuildToken = vi.fn() 19 | const findProjectByToken = vi.fn( 20 | () => new Promise(resolve => resolve({ id: 1 })), 21 | ) 22 | const createBuild = vi.fn( 23 | () => new Promise(resolve => resolve({ id: 1, projectId: 1 })), 24 | ) 25 | const createRun = vi.fn(() => new Promise(resolve => resolve({}))) 26 | const sealBuild = vi.fn(() => new Promise(resolve => resolve({}))) 27 | 28 | vi.mock('@lhci/utils/src/api-client.js', () => { 29 | const ApiClient = vi.fn(() => ({ 30 | setBuildToken, 31 | findProjectByToken, 32 | createBuild, 33 | createRun, 34 | sealBuild, 35 | })) 36 | return { 37 | default: ApiClient, 38 | } 39 | }) 40 | 41 | vi.mock('@lhci/utils/src/build-context.js', () => { 42 | return { 43 | getCommitMessage: vi.fn(() => ''), 44 | getAuthor: vi.fn(() => ''), 45 | getAvatarUrl: vi.fn(() => ''), 46 | getExternalBuildUrl: vi.fn(() => ''), 47 | getCommitTime: vi.fn(() => ''), 48 | getCurrentHash: vi.fn(() => ''), 49 | getCurrentBranch: vi.fn(() => ''), 50 | getAncestorHash: vi.fn(() => ''), 51 | } 52 | }) 53 | 54 | describe('lighthouseServer reports', () => { 55 | it('expanded', async () => { 56 | vi.useFakeTimers() 57 | 58 | await Promise.resolve>(generateReportPayload('lighthouseServer', lighthouseReport, { 59 | lhciHost: 'http://localhost', 60 | lhciBuildToken: 'token', 61 | })) 62 | 63 | expect(ApiClient).toBeCalledWith({ fetch, rootURL: 'http://localhost' }) 64 | expect(setBuildToken).toBeCalledWith('token') 65 | 66 | expect(createBuild).toBeCalledWith({ 67 | projectId: 1, 68 | lifecycle: 'unsealed', 69 | hash: '', 70 | branch: '', 71 | ancestorHash: '', 72 | commitMessage: '', 73 | author: '', 74 | avatarUrl: '', 75 | externalBuildUrl: '', 76 | runAt: new Date().toISOString(), 77 | committedAt: '', 78 | ancestorCommittedAt: undefined, 79 | }) 80 | 81 | expect(fs.readJson).toBeCalledTimes(lighthouseReport.length) 82 | 83 | expect(createRun).toBeCalledTimes(lighthouseReport.length) 84 | 85 | lighthouseReport.forEach(({ route }) => { 86 | expect(createRun).toBeCalledWith({ 87 | projectId: 1, 88 | buildId: 1, 89 | representative: false, 90 | url: `${route.url}${route.path}`, 91 | lhr: '{}', 92 | }) 93 | }) 94 | 95 | expect(sealBuild).toBeCalledTimes(1) 96 | expect(sealBuild).toBeCalledWith(1, 1) 97 | }) 98 | }) 99 | -------------------------------------------------------------------------------- /packages/cli/src/createCli.ts: -------------------------------------------------------------------------------- 1 | import cac from 'cac' 2 | import { version } from '../package.json' 3 | 4 | export default function createCli() { 5 | const cli = cac('unlighthouse') 6 | 7 | cli 8 | .help() 9 | .version(version) 10 | .example('unlighthouse --site unlighthouse.dev') 11 | .example('unlighthouse --site unlighthouse.dev --urls /guide,/api,/glossary --desktop') 12 | 13 | cli.option('--root ', 'Define the project root. Useful for changing where the config is read from or setting up sampling.') 14 | cli.option('--config-file ', 'Path to config file.') 15 | cli.option('--output-path ', 'Path to save the contents of the client and reports to.') 16 | cli.option('--no-cache', 'Disable the caching.') 17 | cli.option('--cache', 'Enable the caching.') 18 | 19 | cli.option('--desktop', 'Simulate device as desktop.') 20 | cli.option('--mobile', 'Simulate device as mobile.') 21 | 22 | cli.option('--site ', 'Host URL to scan.') 23 | cli.option('--user-agent ', 'Specify a top-level user agent all requests will use.') 24 | cli.option('--router-prefix ', 'The URL path prefix for the client and API to run from.') 25 | cli.option('--sitemaps ', 'Comma separated list of sitemaps to use for scanning. Providing these will override any in robots.txt.') 26 | cli.option('--samples ', 'Specify the amount of samples to run.') 27 | cli.option('--throttle', 'Enable the throttling') 28 | cli.option('--enable-javascript', 'When inspecting the HTML wait for the javascript to execute. Useful for SPAs.') 29 | cli.option('--disable-javascript', 'When inspecting the HTML, don\'t wait for the javascript to execute.') 30 | cli.option('--enable-i18n-pages', 'Enable scanning pages which use x-default.') 31 | cli.option('--disable-i18n-pages', 'Disable scanning pages which use x-default.') 32 | cli.option('--urls ', 'Specify explicit relative paths to scan as a comma-separated list, disabling the link crawler.') 33 | cli.option('--exclude-urls ', 'Relative paths (string or regex) to exclude as a comma-separated list.') 34 | cli.option('--include-urls ', 'Relative paths (string or regex) to include as a comma-separated list.') 35 | cli.option('--disable-robots-txt', 'Disables the robots.txt crawling.') 36 | cli.option('--disable-sitemap', 'Disables the sitemap.xml crawling.') 37 | cli.option('--disable-dynamic-sampling', 'Disables the sampling of paths.') 38 | 39 | // add extra-headers, cookies, auth, default-query-params 40 | cli.option('--extra-headers ', 'Extra headers to send with the request. Example: --extra-headers foo=bar,bar=foo') 41 | cli.option('--cookies ', 'Cookies to send with the request. Example: --cookies foo=bar;bar=foo') 42 | cli.option('--auth ', 'Basic auth to send with the request. Example: --auth username:password') 43 | cli.option('--default-query-params ', 'Default query params to send with the request. Example: --default-query-params foo=bar,bar=foo') 44 | 45 | cli.option('-d, --debug', 'Debug. Enable debugging in the logger.') 46 | 47 | return cli 48 | } 49 | -------------------------------------------------------------------------------- /packages/core/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@unlighthouse/core", 3 | "type": "module", 4 | "version": "0.17.4", 5 | "description": "Scan your entire website with Google Lighthouse.", 6 | "license": "MIT", 7 | "homepage": "https://github.com/harlan-zw/unlighthouse#readme", 8 | "repository": { 9 | "type": "git", 10 | "url": "git+https://github.com/harlan-zw/unlighthouse.git", 11 | "directory": "packages/core" 12 | }, 13 | "bugs": { 14 | "url": "https://github.com/harlan-zw/unlighthouse/issues" 15 | }, 16 | "keywords": [ 17 | "lighthouse", 18 | "audit", 19 | "seo", 20 | "performance", 21 | "speed" 22 | ], 23 | "sideEffects": false, 24 | "exports": { 25 | ".": { 26 | "types": "./dist/index.d.mts", 27 | "import": "./dist/index.mjs" 28 | } 29 | }, 30 | "main": "dist/index.mjs", 31 | "module": "dist/index.mjs", 32 | "types": "dist/index.d.mts", 33 | "files": [ 34 | "*.d.ts", 35 | "dist" 36 | ], 37 | "scripts": { 38 | "build": "obuild", 39 | "stub": "obuild --stub", 40 | "test:attw": "attw --pack" 41 | }, 42 | "peerDependenciesMeta": { 43 | "puppeteer": { 44 | "optional": true 45 | } 46 | }, 47 | "dependencies": { 48 | "@clack/prompts": "catalog:dependencies", 49 | "@puppeteer/browsers": "catalog:dependencies", 50 | "@unrouted/core": "catalog:dependencies", 51 | "@unrouted/fetch": "catalog:dependencies", 52 | "@unrouted/plugins": "catalog:dependencies", 53 | "@unrouted/preset-api": "catalog:dependencies", 54 | "@unrouted/preset-node": "catalog:dependencies", 55 | "axios": "catalog:dependencies", 56 | "c12": "catalog:dependencies", 57 | "cheerio": "catalog:dependencies", 58 | "chrome-launcher": "catalog:dependencies", 59 | "consola": "catalog:dependencies", 60 | "defu": "catalog:dependencies", 61 | "execa": "catalog:dependencies", 62 | "fs-extra": "catalog:dependencies", 63 | "globby": "catalog:dependencies", 64 | "h3": "catalog:dependencies", 65 | "hookable": "catalog:dependencies", 66 | "launch-editor": "catalog:dependencies", 67 | "lighthouse": "catalog:dependencies", 68 | "lodash-es": "catalog:dependencies", 69 | "minimist": "catalog:dependencies", 70 | "mlly": "catalog:dependencies", 71 | "object-hash": "catalog:dependencies", 72 | "ofetch": "catalog:dependencies", 73 | "pathe": "catalog:dependencies", 74 | "puppeteer-cluster": "catalog:dependencies", 75 | "puppeteer-core": "catalog:dependencies", 76 | "radix3": "catalog:dependencies", 77 | "regexparam": "catalog:dependencies", 78 | "sanitize-filename": "catalog:dependencies", 79 | "sitemapper": "catalog:dependencies", 80 | "slugify": "catalog:dependencies", 81 | "ufo": "catalog:dependencies", 82 | "unconfig": "catalog:dependencies", 83 | "unctx": "catalog:dependencies", 84 | "wrap-ansi": "catalog:dependencies", 85 | "ws": "catalog:dependencies" 86 | }, 87 | "devDependencies": { 88 | "@types/fs-extra": "catalog:", 89 | "@types/lodash-es": "catalog:", 90 | "@types/object-hash": "catalog:", 91 | "@types/ws": "catalog:" 92 | } 93 | } 94 | -------------------------------------------------------------------------------- /pnpm-workspace.yaml: -------------------------------------------------------------------------------- 1 | catalogMode: prefer 2 | shellEmulator: true 3 | 4 | trustPolicy: no-downgrade 5 | packages: 6 | - packages/* 7 | - crux-api 8 | - '!docs' 9 | catalog: 10 | '@antfu/eslint-config': ^6.7.1 11 | '@arethetypeswrong/cli': 0.18.2 12 | '@types/fs-extra': ^11.0.4 13 | '@types/lodash': ^4.17.21 14 | '@types/lodash-es': ^4.17.12 15 | '@types/object-hash': ^3.0.6 16 | '@types/three': ^0.182.0 17 | '@types/ws': ^8.18.1 18 | '@vitejs/plugin-vue': ^6.0.3 19 | '@vue/compiler-sfc': ^3.5.25 20 | bumpp: ^10.3.2 21 | eslint: ^9.39.2 22 | obuild: ^0.4.8 23 | typescript: ^5.9.3 24 | vitest: ^4.0.16 25 | 26 | catalogs: 27 | crux-api: 28 | nitropack: latest 29 | dependencies: 30 | '@clack/prompts': ^0.11.0 31 | '@headlessui/vue': ^1.7.23 32 | '@iconify-json/carbon': latest 33 | '@iconify-json/ic': latest 34 | '@iconify-json/icomoon-free': latest 35 | '@iconify-json/la': latest 36 | '@iconify-json/logos': latest 37 | '@iconify-json/mdi': latest 38 | '@iconify-json/simple-line-icons': latest 39 | '@iconify-json/vscode-icons': latest 40 | '@lhci/utils': ^0.15.1 41 | '@nuxt/ui': ^4.2.1 42 | '@puppeteer/browsers': ^2.11.0 43 | '@tailwindcss/vite': ^4.1.18 44 | '@unrouted/core': ^0.6.0 45 | '@unrouted/fetch': ^0.6.0 46 | '@unrouted/plugins': ^0.6.0 47 | '@unrouted/preset-api': ^0.6.0 48 | '@unrouted/preset-node': ^0.6.0 49 | '@vueuse/core': ^14.1.0 50 | '@vueuse/router': ^14.1.0 51 | axios: ^1.13.2 52 | better-opn: ^3.0.2 53 | c12: ^3.3.3 54 | cac: ^6.7.14 55 | cheerio: 1.1.0 56 | chrome-launcher: ^1.2.1 57 | consola: ^3.4.2 58 | date-fns: ^4.1.0 59 | dayjs: ^1.11.19 60 | defu: ^6.1.4 61 | execa: ^9.6.1 62 | fs-extra: ^11.3.2 63 | fuse.js: ^7.1.0 64 | globby: ^16.0.0 65 | h3: ^1.15.4 66 | hookable: ^5.5.3 67 | launch-editor: ^2.12.0 68 | lighthouse: ^13.0.1 69 | lightweight-charts: ^5.1.0 70 | listhen: ^1.9.0 71 | lodash-es: ^4.17.22 72 | minimist: ^1.2.8 73 | mlly: ^1.8.0 74 | object-hash: ^3.0.0 75 | ofetch: ^1.5.1 76 | pathe: ^2.0.3 77 | puppeteer-cluster: ^0.25.0 78 | puppeteer-core: ^24.33.0 79 | radix3: ^1.1.2 80 | regexparam: ^3.0.0 81 | sanitize-filename: ^1.6.3 82 | sitemapper: ^4.0.2 83 | slugify: ^1.6.6 84 | std-env: ^3.10.0 85 | tailwindcss: ^4.1.18 86 | three: ^0.182.0 87 | ufo: ^1.6.1 88 | unconfig: ^7.4.2 89 | unctx: ^2.5.0 90 | unplugin-auto-import: ^20.3.0 91 | unplugin-icons: ^22.5.0 92 | unplugin-vue-components: ^30.0.0 93 | vite: ^7.3.0 94 | vite-plugin-pages: ^0.33.2 95 | vue: ^3.5.25 96 | wrap-ansi: ^9.0.2 97 | ws: ^8.18.3 98 | ignoredBuiltDependencies: 99 | - vue-demi 100 | onlyBuiltDependencies: 101 | - '@parcel/watcher' 102 | - '@tailwindcss/oxide' 103 | - esbuild 104 | packageExtensions: 105 | puppeteer-cluster: 106 | peerDependencies: 107 | puppeteer-core: '*' 108 | peerDependenciesMeta: 109 | puppeteer: 110 | optional: true 111 | -------------------------------------------------------------------------------- /docs/1.guide/1.getting-started/0.unlighthouse-cli.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: "Unlighthouse CLI" 3 | description: "Using the CLI is the quickest way to get familiar with Unlighthouse and is recommended for new users." 4 | navigation: 5 | title: "CLI" 6 | --- 7 | 8 | ## Introduction 9 | 10 | The Unlighthouse CLI provides the quickest way to scan your website's Google Lighthouse performance. It requires minimal setup and provides instant feedback through a local development interface. 11 | 12 | ::note 13 | New to Lighthouse? Check out Google's guide on [Lighthouse performance scoring](https://developer.chrome.com/docs/lighthouse/performance/performance-scoring/). 14 | :: 15 | 16 | ## Setup 17 | 18 | ### Requirements 19 | 20 | - Node.js 20.x or higher 21 | - Chrome or Chromium browser (will be auto-installed if missing) 22 | 23 | ### Quick Start 24 | 25 | ::code-group 26 | 27 | ```bash [npm] 28 | npx unlighthouse --site 29 | ``` 30 | 31 | ```bash [pnpm] 32 | pnpm dlx unlighthouse --site 33 | ``` 34 | 35 | ```bash [yarn] 36 | yarn dlx unlighthouse --site 37 | ``` 38 | 39 | :: 40 | 41 | ::tip 42 | Unlighthouse automatically detects and uses your system Chrome or Chromium installation. If neither is found, it will download a compatible Chromium binary. 43 | :: 44 | 45 | ### How It Works 46 | 47 | When you run the CLI command: 48 | 49 | 1. Unlighthouse crawls your site starting from the provided URL 50 | 2. Discovers all internal pages automatically 51 | 3. Runs Google Lighthouse audits on each page 52 | 4. Opens a local UI to view results in real-time 53 | 54 | For detailed CLI options and configuration, see the [CLI Integration](/integrations/cli) guide. 55 | 56 | ## Platform-Specific Notes 57 | 58 | ### Windows WSL 59 | 60 | ::warning 61 | Windows Subsystem for Linux users may encounter connection issues with the Chrome instance. 62 | :: 63 | 64 | For WSL-specific solutions, see [Common Errors Guide](/guide/guides/common-errors#connect-econnrefused-127001port). 65 | 66 | ## Next Steps 67 | 68 | ### Integrations 69 | 70 | Unlighthouse can be integrated into your development workflow: 71 | 72 | - **Build Tools**: [Vite](/integrations/vite), [Webpack](/integrations/webpack), [Nuxt](/integrations/nuxt) 73 | - **CI/CD**: [GitHub Actions, GitLab CI, and more](/integrations/ci) 74 | 75 | Explore all [available integrations](/guide/getting-started/integrations). 76 | 77 | ### Configuration 78 | 79 | Customize Unlighthouse behavior with a configuration file: 80 | 81 | ```ts 82 | // unlighthouse.config.ts 83 | import { defineUnlighthouseConfig } from 'unlighthouse/config' 84 | 85 | export default defineUnlighthouseConfig({ 86 | site: 'https://example.com', 87 | scanner: { 88 | samples: 3, 89 | throttle: true, 90 | }, 91 | }) 92 | ``` 93 | 94 | Learn more in the [Configuration Guide](/guide/guides/config). 95 | 96 | ## Getting Help 97 | 98 | Need assistance? Join our community: 99 | 100 | - 💬 [Discord Community](https://discord.gg/275MBUBvgP) 101 | - 🐛 [Report Issues](https://github.com/harlan-zw/unlighthouse/issues) 102 | - 📖 [Full Documentation](/guide/guides/config) 103 | -------------------------------------------------------------------------------- /packages/client/components/Results/ResultsCell.vue: -------------------------------------------------------------------------------- 1 | 14 | 15 | 73 | -------------------------------------------------------------------------------- /docs/1.guide/guides/url-discovery.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: "URL Discovery" 3 | description: "Learn how Unlighthouse discovers pages using sitemaps, robots.txt, and internal link crawling." 4 | navigation: 5 | title: "URL Discovery" 6 | --- 7 | 8 | Unlighthouse comes with multiple methods for URL discovery in the form of crawling. 9 | 10 | 1. Add the specified `site` from `--site` or config 11 | 2. Manually providing URLs via the `--urls` flag or `urls` on the provider. 12 | 3. `robotsTxt` - Reading robots.txt, if it exists. Provides sitemap URLs and disallowed paths. 13 | 4. `sitemap` - Reading sitemap.xml, if it exists 14 | 5. `crawler` - Inspecting internal links 15 | 6. Using provided static [route definitions](/api/glossary/#route-definition) 16 | 17 | ## Robots.txt 18 | 19 | When a robots.txt is found, it will attempt to read the sitemap and disallowed paths. 20 | 21 | ### Disabling robots 22 | 23 | You may not want to use the robots.txt in all occasions. For example if you want to scan 24 | URLs which are disallowed. 25 | 26 | ```ts 27 | import { defineUnlighthouseConfig } from 'unlighthouse/config' 28 | 29 | export default defineUnlighthouseConfig({ 30 | scanner: { 31 | // disable robots.txt scanning 32 | robotsTxt: false, 33 | }, 34 | }) 35 | ``` 36 | 37 | ## Sitemap.xml 38 | 39 | By default, the sitemap config will be read from your `/robots.txt`. Otherwise, it will fall back to using `/sitemap.xml`. 40 | 41 | Note: When a sitemap exists with over 50 paths, it will disable the crawler. 42 | 43 | ### Manual sitemap paths 44 | 45 | You may provide an array of sitemap paths to scan. 46 | 47 | ```ts 48 | export default defineUnlighthouseConfig({ 49 | scanner: { 50 | sitemap: [ 51 | '/sitemap.xml', 52 | '/sitemap2.xml', 53 | ], 54 | }, 55 | }) 56 | ``` 57 | 58 | ### Disabling scan 59 | 60 | If you know your site doesn't have a sitemap, it may make sense to disable it. 61 | 62 | ```ts 63 | export default defineUnlighthouseConfig({ 64 | scanner: { 65 | // disable sitemap scanning 66 | sitemap: false, 67 | }, 68 | }) 69 | ``` 70 | 71 | ## Crawler 72 | 73 | When enabled, the crawler will inspect the HTML payload of a page and extract internal links. 74 | These internal links will be queued up and scanned if they haven't already been scanned. 75 | 76 | ## Disable crawling 77 | 78 | If you have many pages with many internal links, it may be a good idea to disable the crawling. 79 | 80 | ```ts 81 | export default defineUnlighthouseConfig({ 82 | scanner: { 83 | crawler: false, 84 | }, 85 | }) 86 | ``` 87 | 88 | ## Manually Providing URLs 89 | 90 | While not recommended for most use cases, you may provide relative URLs within your configuration file, or use the `--urls` flag. 91 | 92 | This will disable the crawler and sitemap scanning. 93 | 94 | Can be provided statically. 95 | 96 | ```ts 97 | export default defineUnlighthouseConfig({ 98 | urls: [ 99 | '/about', 100 | '/other-page', 101 | ], 102 | }) 103 | ``` 104 | 105 | Or you can return a function or promise. 106 | 107 | ```ts 108 | export default defineUnlighthouseConfig({ 109 | urls: async () => await getUrls(), 110 | }) 111 | ``` 112 | 113 | Specify explicit relative URLs as a comma-separated list. 114 | 115 | ```bash 116 | unlighthouse --site https://example.com --urls /about,/other-page 117 | ``` 118 | --------------------------------------------------------------------------------