From 4a9ec03873a537b25c07b37851e62dc75e8e8667 Mon Sep 17 00:00:00 2001 From: Julien Huang Date: Thu, 5 Mar 2026 23:03:02 +0100 Subject: [PATCH 1/2] feat: add feature related configuration --- client/app/composables/features.ts | 4 +- package.json | 3 +- pnpm-lock.yaml | 3 + src/features.ts | 19 +- src/module.ts | 4 +- src/runtime/core/features.ts | 13 +- src/runtime/core/types.ts | 21 +- src/runtime/feature-options.ts | 9 + src/runtime/html-validate/nitro.plugin.ts | 48 ++-- src/runtime/html-validate/types.ts | 4 + .../third-party-scripts/plugin.client.ts | 32 ++- src/runtime/third-party-scripts/types.ts | 6 + src/runtime/types.d.ts | 4 +- src/runtime/web-vitals/plugin.client.ts | 241 ++++++++++-------- src/runtime/web-vitals/types.ts | 4 + 15 files changed, 266 insertions(+), 149 deletions(-) create mode 100644 src/runtime/feature-options.ts create mode 100644 src/runtime/third-party-scripts/types.ts create mode 100644 src/runtime/web-vitals/types.ts diff --git a/client/app/composables/features.ts b/client/app/composables/features.ts index c93e531..b4f4cd2 100644 --- a/client/app/composables/features.ts +++ b/client/app/composables/features.ts @@ -11,12 +11,12 @@ export function useEnabledHintsFeatures(): Record { return Object.fromEntries( Object.entries(config.features).map(([feature, flags]) => [ feature, - typeof flags === 'object' ? flags.devtools : Boolean(flags), + typeof flags === 'object' ? flags.devtools !== false : Boolean(flags), ] as [FeaturesName, boolean]), ) as Record } export function useHintsFeature(feature: FeaturesName): boolean { const config = useHintsConfig() - return typeof config.features[feature] === 'object' ? config.features[feature].devtools : Boolean(config.features[feature]) + return typeof config.features[feature] === 'object' ? config.features[feature].devtools !== false : Boolean(config.features[feature]) } diff --git a/package.json b/package.json index f14c1c8..fbd4f7a 100644 --- a/package.json +++ b/package.json @@ -38,6 +38,7 @@ "@nuxt/devtools-kit": "^3.2.2", "@nuxt/kit": "^4.3.1", "consola": "^3.4.2", + "defu": "^6.1.4", "devalue": "^5.6.3", "h3": "^1.15.5", "html-validate": "^10.8.0", @@ -45,8 +46,8 @@ "magic-string": "^0.30.21", "nitropack": "^2.13.1", "oxc-parser": "^0.116.0", - "sirv": "^3.0.2", "prettier": "^3.8.1", + "sirv": "^3.0.2", "unplugin": "^3.0.0", "unstorage": "^1.17.4", "valibot": "^1.2.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 3f5ec45..81c9d0c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -22,6 +22,9 @@ importers: consola: specifier: ^3.4.2 version: 3.4.2 + defu: + specifier: ^6.1.4 + version: 6.1.4 devalue: specifier: ^5.6.3 version: 5.6.3 diff --git a/src/features.ts b/src/features.ts index 9c4afeb..ef234ae 100644 --- a/src/features.ts +++ b/src/features.ts @@ -1,10 +1,27 @@ import type { ModuleOptions } from './module' import type { FeaturesName } from './runtime/core/types' +import type { FeatureOptionsMap } from './runtime/feature-options' export function isFeatureDevtoolsEnabled(options: ModuleOptions, feature: FeaturesName): boolean { - return typeof options.features[feature] === 'object' ? options.features[feature].devtools : !!options.features[feature] + const val = options.features[feature] + return typeof val === 'object' ? val.devtools !== false : !!val } export function isFeatureEnabled(options: ModuleOptions, feature: FeaturesName): boolean { return !!options.features[feature] } + +/** + * Extract the per-feature options from the module config. + * Returns an empty object if the feature is set to a simple boolean. + */ +export function getFeatureOptions( + options: ModuleOptions, + feature: K, +): FeatureOptionsMap[K] { + const val = options.features[feature] + if (typeof val === 'object' && val.options) { + return val.options as FeatureOptionsMap[K] + } + return {} as FeatureOptionsMap[K] +} diff --git a/src/module.ts b/src/module.ts index a4b4e1a..612ecf9 100644 --- a/src/module.ts +++ b/src/module.ts @@ -3,13 +3,13 @@ import { HINTS_SSE_ROUTE } from './runtime/core/server/types' import { setupDevToolsUI } from './devtools' import { InjectHydrationPlugin } from './plugins/hydration' import { LazyLoadHintPlugin } from './plugins/lazy-load' -import type { FeatureFlags, FeaturesName } from './runtime/core/types' +import type { Features } from './runtime/core/types' import { isFeatureDevtoolsEnabled, isFeatureEnabled } from './features' // Module options TypeScript interface definition export interface ModuleOptions { devtools: boolean - features: Record + features: Features } const moduleName = '@nuxt/hints' diff --git a/src/runtime/core/features.ts b/src/runtime/core/features.ts index 97d35ef..97165ec 100644 --- a/src/runtime/core/features.ts +++ b/src/runtime/core/features.ts @@ -1,15 +1,24 @@ // @ts-expect-error virtual file import { features } from '#hints-config' import type { FeaturesName } from './types' +import type { FeatureOptionsMap } from '../feature-options' export function isFeatureDevtoolsEnabled(feature: FeaturesName): boolean { - return typeof features[feature] === 'object' ? features[feature].devtools : !!features[feature] + return typeof features[feature] === 'object' ? features[feature].devtools !== false : !!features[feature] } export function isFeatureLogsEnabled(feature: FeaturesName): boolean { - return typeof features[feature] === 'object' ? features[feature].logs : !!features[feature] + return typeof features[feature] === 'object' ? features[feature].logs !== false : !!features[feature] } export function isFeatureEnabled(feature: FeaturesName): boolean { return !!features[feature] } + +export function getFeatureOptions(feature: K): FeatureOptionsMap[K] | undefined { + const val = features[feature] + if (typeof val === 'object' && val.options) { + return val.options as FeatureOptionsMap[K] + } + return undefined +} diff --git a/src/runtime/core/types.ts b/src/runtime/core/types.ts index 4b51dcd..6508b6a 100644 --- a/src/runtime/core/types.ts +++ b/src/runtime/core/types.ts @@ -1,11 +1,20 @@ +import type { FeatureOptionsMap } from '../feature-options' + export type FeaturesName = 'hydration' | 'lazyLoad' | 'webVitals' | 'thirdPartyScripts' | 'htmlValidate' +export type FeatureFlags = Record> = { + logs?: boolean + devtools?: boolean + /** + * Any feature specific options + */ + options?: T +} + /** - * FF used by modules options and to expose in the payload for devtools + * Fully-resolved features configuration type, where each feature + * can be a simple boolean or a FeatureFlags with its own options. */ -export type FeatureFlags = { - logs: boolean - devtools: boolean +export type Features = { + [K in FeaturesName]: boolean | FeatureFlags> } - -export type Features = Record diff --git a/src/runtime/feature-options.ts b/src/runtime/feature-options.ts new file mode 100644 index 0000000..ca0e902 --- /dev/null +++ b/src/runtime/feature-options.ts @@ -0,0 +1,9 @@ +import type { HtmlValidateFeatureOptions } from './html-validate/types' +import type { ThirdPartyScriptsFeatureOptions } from './third-party-scripts/types' +import type { WebVitalsFeatureOptions } from './web-vitals/types' + +export interface FeatureOptionsMap { + thirdPartyScripts: ThirdPartyScriptsFeatureOptions + htmlValidate: HtmlValidateFeatureOptions + webVitals: WebVitalsFeatureOptions +} diff --git a/src/runtime/html-validate/nitro.plugin.ts b/src/runtime/html-validate/nitro.plugin.ts index 812d80e..746fe48 100644 --- a/src/runtime/html-validate/nitro.plugin.ts +++ b/src/runtime/html-validate/nitro.plugin.ts @@ -1,5 +1,5 @@ import type { NitroAppPlugin } from 'nitropack/types' -import { HtmlValidate } from 'html-validate' +import { HtmlValidate, type ConfigData, type RuleConfig } from 'html-validate' import { addBeforeBodyEndTag } from './utils' import { storage } from './storage' import { randomUUID } from 'crypto' @@ -7,27 +7,35 @@ import { stringify } from 'devalue' import type { HtmlValidateReport } from './types' import { format } from 'prettier/standalone' import html from 'prettier/parser-html' +import { getFeatureOptions } from '../core/features' +import { defu } from 'defu' + +const DEFAULT_EXTENDS = [ + 'html-validate:standard', + 'html-validate:document', + 'html-validate:browser', +] + +const DEFAULT_RULES: RuleConfig = { + 'svg-focusable': 'off', + 'no-unknown-elements': 'error', + 'void-style': 'off', + 'no-trailing-whitespace': 'off', + // Conflict with Nuxt defaults + 'require-sri': 'off', + 'attribute-boolean-style': 'off', + 'doctype-style': 'off', + // Unreasonable rule + 'no-inline-style': 'off', +} export default function (nitro) { - const validator = new HtmlValidate({ - extends: [ - 'html-validate:standard', - 'html-validate:document', - 'html-validate:browser', - ], - rules: { - 'svg-focusable': 'off', - 'no-unknown-elements': 'error', - 'void-style': 'off', - 'no-trailing-whitespace': 'off', - // Conflict with Nuxt defaults - 'require-sri': 'off', - 'attribute-boolean-style': 'off', - 'doctype-style': 'off', - // Unreasonable rule - 'no-inline-style': 'off', - }, - }) + const opts: ConfigData = defu({ + extends: DEFAULT_EXTENDS, + rules: DEFAULT_RULES, + }, getFeatureOptions('htmlValidate') ?? {}) + + const validator = new HtmlValidate(opts) nitro.hooks.hook('render:response', async (response, { event }) => { if (typeof response.body === 'string' && (response.headers?.['Content-Type'] || response.headers?.['content-type'])?.includes('html')) { diff --git a/src/runtime/html-validate/types.ts b/src/runtime/html-validate/types.ts index d489790..d007690 100644 --- a/src/runtime/html-validate/types.ts +++ b/src/runtime/html-validate/types.ts @@ -1,3 +1,7 @@ +import type { ConfigData } from 'html-validate' + +export type HtmlValidateFeatureOptions = ConfigData + export type HtmlValidateReport = { id: string html: string diff --git a/src/runtime/third-party-scripts/plugin.client.ts b/src/runtime/third-party-scripts/plugin.client.ts index 7f92fe9..85922a9 100644 --- a/src/runtime/third-party-scripts/plugin.client.ts +++ b/src/runtime/third-party-scripts/plugin.client.ts @@ -1,13 +1,19 @@ import { defineNuxtPlugin, ref, useNuxtApp } from '#imports' import { defu } from 'defu' import { logger } from './utils' +import { getFeatureOptions } from '../core/features' +import type { ThirdPartyScriptsFeatureOptions } from './types' -const EXTENSIONS_SCHEMES_RE = /^(chrome-extension|moz-extension|safari-extension|ms-browser-extension):/ +const DEFAULT_EXTENSION_SCHEMES = ['chrome-extension', 'moz-extension', 'safari-extension', 'ms-browser-extension'] -function isExtensionScript(src: string) { +function buildExtensionSchemesRegex(schemes: string[]) { + return new RegExp(`^(${schemes.join('|')}):`) +} + +function isExtensionScript(src: string, schemesRegex: RegExp) { try { const url = new URL(src, window.location.origin) - return EXTENSIONS_SCHEMES_RE.test(url.protocol) + return schemesRegex.test(url.protocol) } catch { return false @@ -24,14 +30,30 @@ function isSameOriginScript(src: string) { } } -function isIgnoredScript(src: string) { - return isSameOriginScript(src) || isExtensionScript(src) +function isIgnoredDomain(src: string, ignoredDomains: string[]) { + if (ignoredDomains.length === 0) return false + try { + const url = new URL(src, window.location.origin) + return ignoredDomains.some(domain => url.hostname === domain || url.hostname.endsWith(`.${domain}`)) + } + catch { + return false + } } export default defineNuxtPlugin({ name: 'nuxt-hints:third-party-scripts', setup() { const nuxtApp = useNuxtApp() + const opts = getFeatureOptions('thirdPartyScripts') ?? {} + + const extensionSchemes = [...DEFAULT_EXTENSION_SCHEMES, ...(opts.ignoredSchemes ?? [])] + const schemesRegex = buildExtensionSchemesRegex(extensionSchemes) + const ignoredDomains = opts.ignoredDomains ?? [] + + function isIgnoredScript(src: string) { + return isSameOriginScript(src) || isExtensionScript(src, schemesRegex) || isIgnoredDomain(src, ignoredDomains) + } nuxtApp.payload.__hints = defu(nuxtApp.payload.__hints, { thirdPartyScripts: ref<{ element: HTMLScriptElement, loaded: boolean }[]>([]), diff --git a/src/runtime/third-party-scripts/types.ts b/src/runtime/third-party-scripts/types.ts new file mode 100644 index 0000000..81dfbdc --- /dev/null +++ b/src/runtime/third-party-scripts/types.ts @@ -0,0 +1,6 @@ +export interface ThirdPartyScriptsFeatureOptions { + /** Additional domains to ignore when detecting third-party scripts */ + ignoredDomains?: string[] + /** Additional URL scheme patterns to ignore (default includes browser extension schemes) */ + ignoredSchemes?: string[] +} diff --git a/src/runtime/types.d.ts b/src/runtime/types.d.ts index 4f6fc0c..b7ec9ea 100644 --- a/src/runtime/types.d.ts +++ b/src/runtime/types.d.ts @@ -2,7 +2,7 @@ import type { VNode, Ref } from 'vue' import type { LCPMetricWithAttribution, INPMetricWithAttribution, CLSMetricWithAttribution } from 'web-vitals/attribution' import type { HydrationMismatchPayload, LocalHydrationMismatch } from './hydration/types' import type { DirectImportInfo, LazyHydrationState } from './lazy-load/composables' -import type { FeaturesName, FeatureFlags } from './core/types' +import type { FeaturesName, Features } from './core/types' declare global { interface Window { @@ -40,7 +40,7 @@ declare module '#app' { __tracerRecord: typeof import('vite-plugin-vue-tracer/client/record') hints: { config: { - features: Record + features: Features } } } diff --git a/src/runtime/web-vitals/plugin.client.ts b/src/runtime/web-vitals/plugin.client.ts index 2d7749a..f2b8953 100644 --- a/src/runtime/web-vitals/plugin.client.ts +++ b/src/runtime/web-vitals/plugin.client.ts @@ -3,6 +3,7 @@ import { onINP, onLCP, onCLS } from 'web-vitals/attribution' import { defu } from 'defu' import { ref } from 'vue' import { logger } from './utils' +import { getFeatureOptions } from '../core/features' type ElementNode = ChildNode & { attributes: { href: { value: string } } } @@ -24,10 +25,25 @@ function isImgElement( return element instanceof Element && element.tagName === 'IMG' } +function isIgnoredDomain(src: string, ignoreDomains: string[]): boolean { + if (ignoreDomains.length === 0) return false + try { + const url = new URL(src) + return ignoreDomains.some(domain => url.hostname === domain || url.hostname.endsWith(`.${domain}`)) + } + catch { + return false + } +} + export default defineNuxtPlugin({ name: 'nuxt-hints:performance', setup() { const nuxtApp = useNuxtApp() + const opts = getFeatureOptions('webVitals') ?? {} + const trackedMetrics = opts.trackedMetrics ?? ['LCP', 'INP', 'CLS'] + const ignoreDomains = opts.ignoreDomains ?? [] + nuxtApp.payload.__hints = defu(nuxtApp.payload.__hints, { webvitals: { lcp: ref([]), @@ -43,135 +59,144 @@ export default defineNuxtPlugin({ }) nuxtApp.hook('app:mounted', () => { - onINP((metric) => { - if (metric.rating === 'good') { - return - } - logger.info( - '[web-vitals] INP Metric: ', - metric, - ) - nuxtApp.payload.__hints.webvitals.inp.value.push(metric) - nuxtApp.callHook('hints:webvitals:inp', metric) - }, { - reportAllChanges: true, - }) - - onLCP((metric) => { - if (metric.rating === 'good') { - return - } - logger.info( - `[web-vitals] LCP Metric: `, - metric, - ) - nuxtApp.payload.__hints.webvitals.lcp.value.push(metric) - nuxtApp.callHook('hints:webvitals:lcp', metric) - - for (const performanceEntry of metric.entries) { - if (!performanceEntry.element || !isImgElement(performanceEntry.element)) { - continue + if (trackedMetrics.includes('INP')) { + onINP((metric) => { + if (metric.rating === 'good') { + return } - - if (performanceEntry.element.attributes?.getNamedItem('loading')?.value === 'lazy') { - logger.warn( - '[performance] LCP Element should not have `loading="lazy"` \n\n Learn more: https://web.dev/optimize-lcp/#optimize-the-priority-the-resource-is-given', - ) + logger.info( + '[web-vitals] INP Metric: ', + metric, + ) + nuxtApp.payload.__hints.webvitals.inp.value.push(metric) + nuxtApp.callHook('hints:webvitals:inp', metric) + }, { + reportAllChanges: true, + }) + } + + if (trackedMetrics.includes('LCP')) { + onLCP((metric) => { + if (metric.rating === 'good') { + return } - if (hasImageFormat(performanceEntry.element.src)) { + logger.info( + `[web-vitals] LCP Metric: `, + metric, + ) + nuxtApp.payload.__hints.webvitals.lcp.value.push(metric) + nuxtApp.callHook('hints:webvitals:lcp', metric) + + for (const performanceEntry of metric.entries) { + if (!performanceEntry.element || !isImgElement(performanceEntry.element)) { + continue + } + + if (isIgnoredDomain(performanceEntry.element.src, ignoreDomains)) { + continue + } + + if (performanceEntry.element.attributes?.getNamedItem('loading')?.value === 'lazy') { + logger.warn( + '[performance] LCP Element should not have `loading="lazy"` \n\n Learn more: https://web.dev/optimize-lcp/#optimize-the-priority-the-resource-is-given', + ) + } + if (hasImageFormat(performanceEntry.element.src)) { + if ( + !performanceEntry.element.src.includes('webp') + && !performanceEntry.element.src.includes('avif') + ) { + logger.warn( + '[performance] LCP Element can be served in a next gen format like `webp` or `avif` \n\n Learn more: https://web.dev/choose-the-right-image-format/ \n\n Use: https://image.nuxt.com/usage/nuxt-img#format', + ) + } + } + if (performanceEntry.element.fetchPriority !== 'high') { + logger.warn( + '[performance] LCP Element can have `fetchPriority="high"` to load as soon as possible \n\n Learn more: https://web.dev/optimize-lcp/#optimize-the-priority-the-resource-is-given', + ) + } if ( - !performanceEntry.element.src.includes('webp') - || !performanceEntry.element.src.includes('avif') + !performanceEntry.element.attributes.getNamedItem('height') + || !performanceEntry.element.attributes.getNamedItem('width') ) { logger.warn( - '[performance] LCP Element can be served in a next gen format like `webp` or `avif` \n\n Learn more: https://web.dev/choose-the-right-image-format/ \n\n Use: https://image.nuxt.com/usage/nuxt-img#format', + '[performance] Images should have `width` and `height` sizes set \n\n Learn more: https://web.dev/optimize-cls/#images-without-dimensions \n\n Use: https://image.nuxt.com/usage/nuxt-img#width-height', + ) + } + if (performanceEntry.startTime > 2500) { + logger.warn( + `[performance] LCP Element loaded in ${performanceEntry.startTime} miliseconds. Good result is below 2500 miliseconds \n\n Learn more: https://web.dev/lcp/#what-is-a-good-lcp-score`, + ) + } + + if (!isElementPreloaded(performanceEntry.element.src)) { + logger.warn( + '[performance] LCP Element can be preloaded in `head` to improve load time \n\n Learn more: https://web.dev/optimize-lcp/#optimize-when-the-resource-is-discovered \n\n Use: https://image.nuxt.com/usage/nuxt-img#preload', ) } } - if (performanceEntry.element.fetchPriority !== 'high') { - logger.warn( - '[performance] LCP Element can have `fetchPriority="high"` to load as soon as possible \n\n Learn more: https://web.dev/optimize-lcp/#optimize-the-priority-the-resource-is-given', - ) - } - if ( - !performanceEntry.element.attributes.getNamedItem('height') - || !performanceEntry.element.attributes.getNamedItem('width') - ) { - logger.warn( - '[performance] Images should have `width` and `height` sizes set \n\n Learn more: https://web.dev/optimize-cls/#images-without-dimensions \n\n Use: https://image.nuxt.com/usage/nuxt-img#width-height', - ) - } - if (performanceEntry.startTime > 2500) { - logger.warn( - `[performance] LCP Element loaded in ${performanceEntry.startTime} miliseconds. Good result is below 2500 miliseconds \n\n Learn more: https://web.dev/lcp/#what-is-a-good-lcp-score`, - ) + }, { + reportAllChanges: true, + }) + } + + if (trackedMetrics.includes('CLS')) { + onCLS((metric) => { + if (metric.rating === 'good') { + return } + logger.info( + '[web-vitals] CLS Metric: ', metric, + ) + nuxtApp.callHook( + 'hints:webvitals:cls', + metric, + ) + // Push the metric as-is; components will access entries[0] directly for element + nuxtApp.payload.__hints.webvitals.cls.value.push(metric) - if (!isElementPreloaded(performanceEntry.element.src)) { - logger.warn( + for (const entry of metric.entries) { + const performanceEntry = entry - '[performance] LCP Element can be preloaded in `head` to improve load time \n\n Learn more: https://web.dev/optimize-lcp/#optimize-when-the-resource-is-discovered \n\n Use: https://image.nuxt.com/usage/nuxt-img#preload', - ) - } - } - }, { - reportAllChanges: true, - }) - - onCLS((metric) => { - if (metric.rating === 'good') { - return - } - logger.info( - '[web-vitals] CLS Metric: ', metric, - ) - nuxtApp.callHook( - 'hints:webvitals:cls', - metric, - ) - // Push the metric as-is; components will access entries[0] directly for element - nuxtApp.payload.__hints.webvitals.cls.value.push(metric) - - for (const entry of metric.entries) { - const performanceEntry = entry - - if (!performanceEntry.sources?.[0]) return - - const sourceElement = performanceEntry.sources?.[0].node - - // Nuxt DevTools button causes small layout shift so we ignore it - if (!sourceElement || sourceElement.parentElement?.className.includes('nuxt-devtools')) return + if (!performanceEntry.sources?.[0]) return - logger.info( - '[performance] Potential CLS Element: ', - sourceElement, - ) + const sourceElement = performanceEntry.sources?.[0].node - if ((performanceEntry.value ?? 0) > 0.1) { - logger.warn( - `[performance] CLS was ${performanceEntry.value}. Good result is below 0.1 \n\n Learn more: https://web.dev/articles/cls#what-is-a-good-cls-score`, - ) - } + // Nuxt DevTools button causes small layout shift so we ignore it + if (!sourceElement || sourceElement.parentElement?.className.includes('nuxt-devtools')) return - if ( - isImgElement(sourceElement) - && (!sourceElement.attributes.getNamedItem('height') - || !sourceElement.attributes.getNamedItem('width')) - ) { - logger.warn( - '[performance] Images should have `width` and `height` sizes set \n\n Learn more: https://web.dev/optimize-cls/#images-without-dimensions \n\n Use: https://image.nuxt.com/usage/nuxt-img#width-height', + logger.info( + '[performance] Potential CLS Element: ', + sourceElement, ) + + if ((performanceEntry.value ?? 0) > 0.1) { + logger.warn( + `[performance] CLS was ${performanceEntry.value}. Good result is below 0.1 \n\n Learn more: https://web.dev/articles/cls#what-is-a-good-cls-score`, + ) + } + + if ( + isImgElement(sourceElement) + && (!sourceElement.attributes.getNamedItem('height') + || !sourceElement.attributes.getNamedItem('width')) + ) { + logger.warn( + '[performance] Images should have `width` and `height` sizes set \n\n Learn more: https://web.dev/optimize-cls/#images-without-dimensions \n\n Use: https://image.nuxt.com/usage/nuxt-img#width-height', + ) + } } - } - }) + }) + } }) }, }) -const hasImageFormat = (src: string) => { - const imageFormats = ['avif', 'jpg', 'jpeg', 'png', 'webp'] +const IMAGE_FORMATS = ['avif', 'jpg', 'jpeg', 'png', 'webp'] - return imageFormats.some(format => src.includes(format)) +const hasImageFormat = (src: string) => { + return IMAGE_FORMATS.some(format => src.includes(format)) } const isElementPreloaded = (src: string) => { diff --git a/src/runtime/web-vitals/types.ts b/src/runtime/web-vitals/types.ts new file mode 100644 index 0000000..60a683a --- /dev/null +++ b/src/runtime/web-vitals/types.ts @@ -0,0 +1,4 @@ +export interface WebVitalsFeatureOptions { + ignoreDomains?: string[] + trackedMetrics?: Array<'LCP' | 'INP' | 'CLS'> +} From 7fffd5fdd0819a6b8fb87dbba68989b11fde0a61 Mon Sep 17 00:00:00 2001 From: Julien Huang Date: Thu, 5 Mar 2026 23:45:47 +0100 Subject: [PATCH 2/2] tesdt: add tests --- test/unit/build-features.test.ts | 170 +++++++++++++++++++++++++++++++ test/unit/core/features.test.ts | 110 ++++++++++++++++++++ 2 files changed, 280 insertions(+) create mode 100644 test/unit/build-features.test.ts create mode 100644 test/unit/core/features.test.ts diff --git a/test/unit/build-features.test.ts b/test/unit/build-features.test.ts new file mode 100644 index 0000000..3170be8 --- /dev/null +++ b/test/unit/build-features.test.ts @@ -0,0 +1,170 @@ +import { describe, it, expect } from 'vitest' +import { isFeatureDevtoolsEnabled, isFeatureEnabled, getFeatureOptions } from '../../src/features' +import type { ModuleOptions } from '../../src/module' + +function makeOptions(features: ModuleOptions['features']): ModuleOptions { + return { devtools: true, features } +} + +describe('build-time feature helpers', () => { + describe('isFeatureEnabled', () => { + it('returns true when feature is set to true', () => { + const opts = makeOptions({ + hydration: true, + lazyLoad: true, + webVitals: true, + thirdPartyScripts: true, + htmlValidate: true, + }) + expect(isFeatureEnabled(opts, 'hydration')).toBe(true) + }) + + it('returns false when feature is set to false', () => { + const opts = makeOptions({ + hydration: false, + lazyLoad: true, + webVitals: true, + thirdPartyScripts: true, + htmlValidate: true, + }) + expect(isFeatureEnabled(opts, 'hydration')).toBe(false) + }) + + it('returns true when feature is an object', () => { + const opts = makeOptions({ + hydration: { logs: true }, + lazyLoad: true, + webVitals: true, + thirdPartyScripts: true, + htmlValidate: true, + }) + expect(isFeatureEnabled(opts, 'hydration')).toBe(true) + }) + }) + + describe('isFeatureDevtoolsEnabled', () => { + it('returns true for a boolean-enabled feature', () => { + const opts = makeOptions({ + hydration: true, + lazyLoad: true, + webVitals: true, + thirdPartyScripts: true, + htmlValidate: true, + }) + expect(isFeatureDevtoolsEnabled(opts, 'hydration')).toBe(true) + }) + + it('returns false for a boolean-disabled feature', () => { + const opts = makeOptions({ + hydration: false, + lazyLoad: true, + webVitals: true, + thirdPartyScripts: true, + htmlValidate: true, + }) + expect(isFeatureDevtoolsEnabled(opts, 'hydration')).toBe(false) + }) + + it('reads devtools flag from object config', () => { + const opts = makeOptions({ + hydration: true, + lazyLoad: true, + webVitals: { devtools: false }, + thirdPartyScripts: { devtools: true }, + htmlValidate: true, + }) + expect(isFeatureDevtoolsEnabled(opts, 'webVitals')).toBe(false) + expect(isFeatureDevtoolsEnabled(opts, 'thirdPartyScripts')).toBe(true) + }) + + it('defaults devtools to true when not specified in object', () => { + const opts = makeOptions({ + hydration: true, + lazyLoad: true, + webVitals: { logs: false }, + thirdPartyScripts: true, + htmlValidate: true, + }) + expect(isFeatureDevtoolsEnabled(opts, 'webVitals')).toBe(true) + }) + }) + + describe('getFeatureOptions', () => { + it('returns the options object for a feature with options', () => { + const opts = makeOptions({ + hydration: true, + lazyLoad: true, + webVitals: { + options: { + ignoreDomains: ['cdn.example.com'], + trackedMetrics: ['LCP', 'INP'], + }, + }, + thirdPartyScripts: true, + htmlValidate: true, + }) + expect(getFeatureOptions(opts, 'webVitals')).toEqual({ + ignoreDomains: ['cdn.example.com'], + trackedMetrics: ['LCP', 'INP'], + }) + }) + + it('returns empty object when feature is a boolean', () => { + const opts = makeOptions({ + hydration: true, + lazyLoad: true, + webVitals: true, + thirdPartyScripts: true, + htmlValidate: true, + }) + expect(getFeatureOptions(opts, 'thirdPartyScripts')).toEqual({}) + }) + + it('returns empty object when feature object has no options key', () => { + const opts = makeOptions({ + hydration: true, + lazyLoad: true, + webVitals: true, + thirdPartyScripts: { logs: false }, + htmlValidate: true, + }) + expect(getFeatureOptions(opts, 'thirdPartyScripts')).toEqual({}) + }) + + it('returns thirdPartyScripts options', () => { + const opts = makeOptions({ + hydration: true, + lazyLoad: true, + webVitals: true, + thirdPartyScripts: { + options: { + ignoredDomains: ['tracker.example.com'], + ignoredSchemes: ['custom://'], + }, + }, + htmlValidate: true, + }) + expect(getFeatureOptions(opts, 'thirdPartyScripts')).toEqual({ + ignoredDomains: ['tracker.example.com'], + ignoredSchemes: ['custom://'], + }) + }) + + it('returns htmlValidate options', () => { + const opts = makeOptions({ + hydration: true, + lazyLoad: true, + webVitals: true, + thirdPartyScripts: true, + htmlValidate: { + options: { + rules: { 'no-inline-style': 'off' }, + }, + }, + }) + expect(getFeatureOptions(opts, 'htmlValidate')).toEqual({ + rules: { 'no-inline-style': 'off' }, + }) + }) + }) +}) diff --git a/test/unit/core/features.test.ts b/test/unit/core/features.test.ts new file mode 100644 index 0000000..9a90c1b --- /dev/null +++ b/test/unit/core/features.test.ts @@ -0,0 +1,110 @@ +import { describe, it, expect, vi } from 'vitest' + +const mockFeatures = vi.hoisted(() => ({ + hydration: true, + lazyLoad: false, + webVitals: { + logs: true, + devtools: false, + options: { + ignoreDomains: ['cdn.example.com'], + trackedMetrics: ['LCP', 'CLS'], + }, + }, + thirdPartyScripts: { + logs: false, + devtools: true, + options: { + ignoredDomains: ['analytics.example.com'], + ignoredSchemes: ['custom-scheme://'], + }, + }, + htmlValidate: { + devtools: true, + }, +})) + +vi.mock('#hints-config', () => ({ + features: mockFeatures, +})) + +const { + isFeatureEnabled, + isFeatureDevtoolsEnabled, + isFeatureLogsEnabled, + getFeatureOptions, +} = await import('../../../src/runtime/core/features') + +describe('runtime feature helpers', () => { + describe('isFeatureEnabled', () => { + it('returns true for a boolean-enabled feature', () => { + expect(isFeatureEnabled('hydration')).toBe(true) + }) + + it('returns false for a boolean-disabled feature', () => { + expect(isFeatureEnabled('lazyLoad')).toBe(false) + }) + + it('returns true for an object-configured feature', () => { + expect(isFeatureEnabled('webVitals')).toBe(true) + }) + }) + + describe('isFeatureDevtoolsEnabled', () => { + it('returns true for a boolean-enabled feature (defaults to true)', () => { + expect(isFeatureDevtoolsEnabled('hydration')).toBe(true) + }) + + it('returns false for a boolean-disabled feature', () => { + expect(isFeatureDevtoolsEnabled('lazyLoad')).toBe(false) + }) + + it('reads devtools flag from object config', () => { + expect(isFeatureDevtoolsEnabled('webVitals')).toBe(false) + expect(isFeatureDevtoolsEnabled('thirdPartyScripts')).toBe(true) + }) + + it('defaults to true when devtools is not set in object config', () => { + expect(isFeatureDevtoolsEnabled('htmlValidate')).toBe(true) + }) + }) + + describe('isFeatureLogsEnabled', () => { + it('returns true for a boolean-enabled feature (defaults to true)', () => { + expect(isFeatureLogsEnabled('hydration')).toBe(true) + }) + + it('returns false for a boolean-disabled feature', () => { + expect(isFeatureLogsEnabled('lazyLoad')).toBe(false) + }) + + it('reads logs flag from object config', () => { + expect(isFeatureLogsEnabled('webVitals')).toBe(true) + expect(isFeatureLogsEnabled('thirdPartyScripts')).toBe(false) + }) + + it('defaults to true when logs is not set in object config', () => { + expect(isFeatureLogsEnabled('htmlValidate')).toBe(true) + }) + }) + + describe('getFeatureOptions', () => { + it('returns options for a feature with options set', () => { + expect(getFeatureOptions('webVitals')).toEqual({ + ignoreDomains: ['cdn.example.com'], + trackedMetrics: ['LCP', 'CLS'], + }) + }) + + it('returns options for thirdPartyScripts', () => { + expect(getFeatureOptions('thirdPartyScripts')).toEqual({ + ignoredDomains: ['analytics.example.com'], + ignoredSchemes: ['custom-scheme://'], + }) + }) + + it('returns undefined for a feature with no options', () => { + expect(getFeatureOptions('htmlValidate')).toBeUndefined() + }) + }) +})