├── .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 |
2 |
3 |
4 |
5 |
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 |
2 |
3 |
4 |
5 |
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 |
2 |
3 |
4 |
5 |
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 |
2 |
3 |
4 |
5 |
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 |
2 |
3 |
4 |
5 |
6 |
--------------------------------------------------------------------------------
/packages/client/components/Card.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
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 |
2 |
5 |
6 |
7 |
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 |
5 |
6 |
7 |
--------------------------------------------------------------------------------
/packages/client/components/Btn/BtnAction.vue:
--------------------------------------------------------------------------------
1 |
2 |
5 |
6 |
7 |
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 |
2 |
7 |
8 |
9 |
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 |
15 |
18 |
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 |
12 |
13 |
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 |
16 |
23 |
24 |
25 |
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 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
--------------------------------------------------------------------------------
/packages/client/components/Results/ResultsRoute.vue:
--------------------------------------------------------------------------------
1 |
9 |
10 |
11 |
12 |
13 |
17 |
18 |
19 |
20 |
21 |
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 |
12 |
13 |
robots.txt blocking
14 |
{{ value.details.items }}
15 |
16 |
17 |
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 |
8 |
9 |
17 |
18 |
19 |
20 |
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 |
6 |
7 |
8 |
16 |
17 |
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 |
13 |
21 |
22 |
23 |
30 |
31 |
32 |
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 |
14 |
15 |
16 |
17 |
18 | {{ framework.version }}
19 |
20 |
21 |
22 |
23 | {{ framework.name }}
24 |
25 |
26 |
27 |
28 |
29 |
--------------------------------------------------------------------------------
/packages/client/components/Card/CardRouteScanProgress.vue:
--------------------------------------------------------------------------------
1 |
12 |
13 |
14 |
15 |
16 |
17 |
22 |
28 |
29 |
30 |
31 |
32 |
33 |
34 |
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 |
19 |
20 |
21 |
22 |
23 |
24 |
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 |
15 |
16 |
20 | {{ label }}
21 |
22 |
32 |
33 | {{ value }}
34 |
35 |
36 |
37 |
38 |
--------------------------------------------------------------------------------
/packages/client/components/Cell/CellColorContrast.vue:
--------------------------------------------------------------------------------
1 |
11 |
12 |
13 |
14 |
17 |
18 |
27 | {{ node.nodeLabel }}
28 |
29 |
30 |
31 |
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 |
6 |
7 |
18 |
23 |
24 |
25 |
26 |
27 |
28 |
29 |
--------------------------------------------------------------------------------
/packages/client/components/ModalTrigger.vue:
--------------------------------------------------------------------------------
1 |
21 |
22 |
23 |
24 |
25 |
30 |
31 |
32 |
33 |
34 |
35 |
36 |
37 |
38 |
39 |
--------------------------------------------------------------------------------
/packages/client/components/Card/CardModuleSizes.vue:
--------------------------------------------------------------------------------
1 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 | Vendor Bundle
21 |
{{ stats.modules.vendor.size }}
22 |
23 |
24 |
25 | Commons Bundle
26 |
{{ stats.modules.commons.size }}
27 |
28 |
29 |
30 |
31 | App Bundle
32 |
{{ stats.modules.app.size }}
33 |
34 |
35 |
36 |
37 |
38 |
--------------------------------------------------------------------------------
/packages/client/components/Cell/CellImageIssues.vue:
--------------------------------------------------------------------------------
1 |
30 |
31 |
32 |
35 |
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 |
5 |
6 |
9 |
10 |
15 |
16 |
24 |
25 |
26 |
27 |
28 |
29 |
30 |
--------------------------------------------------------------------------------
/packages/client/components/Loading/LoadingStatusIcon.vue:
--------------------------------------------------------------------------------
1 |
43 |
44 |
45 |
46 |
47 | {{ label }}
48 |
49 |
50 |
--------------------------------------------------------------------------------
/packages/client/components/Cell/CellMetaDescription.vue:
--------------------------------------------------------------------------------
1 |
49 |
50 |
51 |
52 |
53 | {{ value }}
54 |
55 |
58 |
59 |
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 |
12 |
13 |
14 |
19 |
24 |
25 |
26 |
View Lighthouse Report
27 |
28 |
29 |
30 |
--------------------------------------------------------------------------------
/packages/client/components/AuditResultWithTooltip.vue:
--------------------------------------------------------------------------------
1 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 | {{ audit.title }}
18 |
19 |
20 | {{ audit.description }}
21 |
22 |
23 |
24 |
25 | {{ item.url || item.node?.selector || item.source }}
26 |
27 |
28 | Size: {{ item.transferSize }}
29 |
30 |
31 |
32 | ... and {{ items.length - 5 }} more
33 |
34 |
35 |
36 |
37 |
38 |
39 |
40 |
41 |
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 |
2 |
3 |
4 | Layer 1
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
--------------------------------------------------------------------------------
/packages/client/public/assets/logo.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 | Layer 1
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
--------------------------------------------------------------------------------
/packages/client/public/assets/logo-dark.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 | Layer 1
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
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 |
32 |
33 |
34 |
35 |
36 |
37 |
38 |
39 |
40 |
41 |
42 |
43 |
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 |
42 |
43 |
44 |
45 |
46 |
47 |
48 |
49 |
50 |
51 |
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 |
46 |
47 |
48 |
49 | {{ value.displayValue }}
50 |
51 |
52 |
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 |
8 |
9 |
17 |
23 |
24 |
25 |
33 |
37 |
44 |
45 |
46 |
47 |
48 |
--------------------------------------------------------------------------------
/packages/client/components/Cell/CellLayoutShift.vue:
--------------------------------------------------------------------------------
1 |
29 |
30 |
31 |
32 |
33 |
34 |
35 |
36 |
37 |
38 |
39 |
40 |
41 |
42 |
43 |
44 |
45 |
46 |
47 |
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 |
29 |
30 |
31 |
36 |
37 |
38 |
39 | Lighthouse Report
40 |
41 |
42 |
43 |
44 |
45 |
46 |
47 | Lighthouse is running with variability. Performance scores should not be considered accurate.
48 |
49 | Unlighthouse is running with{{ throttle ? '' : 'out' }} throttling which will also effect scores.
50 |
51 |
52 |
PSI Test
53 |
54 |
55 |
56 |
--------------------------------------------------------------------------------
/packages/client/components/Cell/CellWebVitals.vue:
--------------------------------------------------------------------------------
1 |
9 |
10 |
11 |
12 |
13 |
14 |
15 | LCP
16 |
17 |
18 | {{ report.report.audits['largest-contentful-paint'].title }}
19 |
20 | {{ report.report.audits['largest-contentful-paint'].description }}
21 |
22 |
23 |
24 |
25 |
26 |
27 | FID
28 |
29 |
30 | {{ report.report.audits['max-potential-fid'].title }}
31 |
32 | {{ report.report.audits['max-potential-fid'].description }}
33 |
34 |
35 |
36 |
37 |
38 |
39 | CLS
40 |
41 |
42 | {{ report.report.audits['cumulative-layout-shift'].title }}
43 |
44 | {{ report.report.audits['cumulative-layout-shift'].description }}
45 |
46 |
47 |
48 |
49 |
50 |
51 |
52 |
--------------------------------------------------------------------------------
/packages/client/components/Results/ResultsTableHead.vue:
--------------------------------------------------------------------------------
1 |
21 |
22 |
23 |
27 |
28 |
29 | {{ column.label }}
30 |
31 |
32 |
33 |
34 |
35 |
36 |
37 |
38 | {{ column.label }}
39 |
40 |
46 |
47 |
48 |
49 |
50 |
51 |
52 |
53 |
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 |
37 |
38 |
39 | {{ totalTransfer }}
40 | {{ value.details.items.length }} total
41 |
42 |
43 |
44 |
45 | {{ group.count > 1 ? group.count : '' }} {{ resourceType }}{{ group.count > 1 ? 's' : '' }}
46 | {{ group.size }}
47 |
48 |
49 |
{{ item.url.replace(website, '') }}
50 |
{{ formatBytes(item.transferSize) }}
51 |
{{ Math.round(item.networkEndTime - item.networkRequestTime) }}ms
52 |
53 |
54 |
55 |
56 |
57 |
58 |
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 | |
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 | 
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 |
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 |
16 |
20 |
21 |
22 |
23 |
24 |
25 |
26 |
27 | {{ value }}
28 |
29 |
30 | {{ value }}
31 |
32 |
33 |
34 |
35 |
36 |
37 |
38 |
39 | {{ item.node?.nodeLabel }}
40 |
41 |
42 | {{ item.node.snippet }}
43 |
44 |
45 |
46 |
47 | {{ item.description }}
48 |
49 |
50 | {{ item.sourceLocation.url }}
51 |
52 |
53 |
54 | {{ k }}: {{ v }}
55 |
56 |
57 |
58 |
59 |
60 |
61 | {{ value }}
62 |
63 |
64 | {{ value }}
65 |
66 |
67 |
68 |
69 |
70 |
71 |
72 |
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 |
--------------------------------------------------------------------------------