From ecbcb37197d1971c4ec96b99f263d8a256cb305d Mon Sep 17 00:00:00 2001 From: Harlan Wilton Date: Thu, 14 May 2026 16:18:25 +1000 Subject: [PATCH] feat: warn on misconfigured NUXT_PUBLIC_SCRIPTS_* env vars Nuxt resolves env vars against runtimeConfig before modules run, so a mistyped `NUXT_PUBLIC_SCRIPTS_*` key (e.g. using a marketing name like `MICROSOFT_CLARITY` instead of the registry key `CLARITY`) is silently dropped and the script loads with an empty option. Add a build-time scan over `process.env` that warns when an env var with the public scripts prefix can't be resolved: unknown registry key (with a suggested correction), unknown field on a valid key (listing valid fields), or a valid key whose script isn't registered. --- packages/script/src/module.ts | 9 ++ packages/script/src/validate-env.ts | 163 ++++++++++++++++++++++++++++ test/unit/validate-env.test.ts | 88 +++++++++++++++ 3 files changed, 260 insertions(+) create mode 100644 packages/script/src/validate-env.ts create mode 100644 test/unit/validate-env.test.ts diff --git a/packages/script/src/module.ts b/packages/script/src/module.ts index a270be60..1290f7b9 100644 --- a/packages/script/src/module.ts +++ b/packages/script/src/module.ts @@ -40,6 +40,7 @@ import { generateInterceptPluginContents } from './plugins/intercept' import { NuxtScriptBundleTransformer } from './plugins/transform' import { buildProxyConfigsFromRegistry, generatePartytownResolveUrl, getPartytownForwards, registry, resolveCapabilities } from './registry' import { registerTypeTemplates, templatePlugin, templateTriggerResolver } from './templates' +import { validateScriptsEnvVars } from './validate-env' export type { FirstPartyPrivacy } @@ -556,6 +557,14 @@ export default defineNuxtModule({ ) } + // Surface env vars under `NUXT_PUBLIC_SCRIPTS_*` that won't be consumed: + // wrong key (typo / marketing name), wrong field, or script not registered. + validateScriptsEnvVars( + scripts, + new Set(Object.keys(config.registry || {}).filter(k => (config.registry as any)?.[k] !== false)), + logger, + ) + // Setup runtimeConfig for proxies and devtools. // Must run AFTER env var resolution above so the API key is populated. const googleMapsEnabled = config.googleStaticMapsProxy?.enabled || !!config.registry?.googleMaps diff --git a/packages/script/src/validate-env.ts b/packages/script/src/validate-env.ts new file mode 100644 index 00000000..798d8427 --- /dev/null +++ b/packages/script/src/validate-env.ts @@ -0,0 +1,163 @@ +import type { ConsolaInstance } from 'consola' +import type { RegistryScript } from './runtime/types' + +const UPPER_RE = /([A-Z])/g +const toScreamingSnake = (s: string) => s.replace(UPPER_RE, '_$1').toUpperCase() + +const ENV_PREFIX = 'NUXT_PUBLIC_SCRIPTS_' + +function levenshtein(a: string, b: string): number { + if (a === b) + return 0 + if (!a.length) + return b.length + if (!b.length) + return a.length + const prev: number[] = [] + for (let j = 0; j <= b.length; j++) prev.push(j) + for (let i = 1; i <= a.length; i++) { + let prevDiag = prev[0]! + prev[0] = i + for (let j = 1; j <= b.length; j++) { + const tmp = prev[j]! + prev[j] = a[i - 1] === b[j - 1] + ? prevDiag + : Math.min(prevDiag, prev[j]!, prev[j - 1]!) + 1 + prevDiag = tmp + } + } + return prev[b.length]! +} + +/** + * Warn for `NUXT_PUBLIC_SCRIPTS_*` env vars that don't map to a valid registry + * key + field. Nuxt resolves env vars against runtimeConfig before modules run, + * so a misspelled key (e.g. `NUXT_PUBLIC_SCRIPTS_MICROSOFT_CLARITY_ID` instead + * of `NUXT_PUBLIC_SCRIPTS_CLARITY_ID`) is silently dropped with no error. + */ +export function validateScriptsEnvVars( + scripts: RegistryScript[], + enabledRegistryKeys: Set, + logger: ConsolaInstance, +): void { + // Build a map from screaming-snake registry key to its envDefaults fields + const validByKey = new Map }>() + for (const s of scripts) { + if (!s.registryKey || !s.envDefaults || !Object.keys(s.envDefaults).length) + continue + const screaming = toScreamingSnake(s.registryKey) + const fields = new Set(Object.keys(s.envDefaults).map(toScreamingSnake)) + validByKey.set(screaming, { camel: s.registryKey, fields }) + } + + if (!validByKey.size) + return + + const allValidEnvKeys: string[] = [] + for (const [screaming, { fields }] of validByKey) { + for (const f of fields) + allValidEnvKeys.push(`${ENV_PREFIX}${screaming}_${f}`) + } + + for (const envKey of Object.keys(process.env)) { + if (!envKey.startsWith(ENV_PREFIX)) + continue + if (allValidEnvKeys.includes(envKey)) + continue + + const segment = envKey.slice(ENV_PREFIX.length) + + // Case 1: matches a valid registry key but unknown field + let matchedKey: { screaming: string, camel: string, fields: Set } | undefined + for (const [screaming, info] of validByKey) { + if (segment === screaming || segment.startsWith(`${screaming}_`)) { + matchedKey = { screaming, ...info } + break + } + } + + if (matchedKey) { + const field = segment.slice(matchedKey.screaming.length + 1) + logger.warn( + `[scripts] env var \`${envKey}\` does not match any option on \`${matchedKey.camel}\`. ` + + `Valid fields: ${[...matchedKey.fields].map(f => `\`${ENV_PREFIX}${matchedKey!.screaming}_${f}\``).join(', ')}.${ + field ? ` Got: \`${field}\`.` : ''}`, + ) + continue + } + + // Case 2: registry key appears as a substring of the segment (e.g. + // `MICROSOFT_CLARITY_ID` contains `CLARITY`). Likely a marketing-name + // prefix; suggest the canonical key, and if the remainder is a valid + // field, suggest the full corrected env var. + const segmentParts = segment.split('_') + let substringMatch: { screaming: string, camel: string, fields: Set, remainder: string } | undefined + for (const [screaming, info] of validByKey) { + const keyParts = screaming.split('_') + for (let i = 0; i <= segmentParts.length - keyParts.length; i++) { + let ok = true + for (let j = 0; j < keyParts.length; j++) { + if (segmentParts[i + j] !== keyParts[j]) { + ok = false + break + } + } + if (ok) { + substringMatch = { + screaming, + camel: info.camel, + fields: info.fields, + remainder: segmentParts.slice(i + keyParts.length).join('_'), + } + break + } + } + if (substringMatch) + break + } + + let suggestion = '' + if (substringMatch) { + if (substringMatch.remainder && substringMatch.fields.has(substringMatch.remainder)) { + suggestion = ` Did you mean \`${ENV_PREFIX}${substringMatch.screaming}_${substringMatch.remainder}\` (registry key \`${substringMatch.camel}\`)?` + } + else { + suggestion = ` Did you mean registry key \`${substringMatch.camel}\` (\`${ENV_PREFIX}${substringMatch.screaming}_*\`)?` + } + } + else { + // Fallback: closest registry key by edit distance on the leading parts + let best: { key: string, camel: string, dist: number } | undefined + for (const [screaming, info] of validByKey) { + const head = segmentParts.slice(0, screaming.split('_').length).join('_') + const d = levenshtein(head, screaming) + if (!best || d < best.dist) + best = { key: screaming, camel: info.camel, dist: d } + } + if (best && best.dist <= Math.max(2, Math.floor(best.key.length / 2))) + suggestion = ` Did you mean registry key \`${best.camel}\` (\`${ENV_PREFIX}${best.key}_*\`)?` + } + + logger.warn( + `[scripts] env var \`${envKey}\` does not map to any registered script.${ + suggestion}`, + ) + } + + // Case 3: env var maps to a known script that the user hasn't enabled in + // their nuxt.config registry. The value gets resolved into runtimeConfig + // but won't be consumed unless the script is registered. + for (const [screaming, info] of validByKey) { + if (enabledRegistryKeys.has(info.camel)) + continue + for (const field of info.fields) { + const envKey = `${ENV_PREFIX}${screaming}_${field}` + if (process.env[envKey] !== undefined) { + logger.warn( + `[scripts] env var \`${envKey}\` is set but \`${info.camel}\` is not registered in \`scripts.registry\`. ` + + `Add \`registry: { ${info.camel}: {} }\` to your nuxt.config for it to take effect.`, + ) + } + } + } +} diff --git a/test/unit/validate-env.test.ts b/test/unit/validate-env.test.ts new file mode 100644 index 00000000..8f1f6e8a --- /dev/null +++ b/test/unit/validate-env.test.ts @@ -0,0 +1,88 @@ +import type { ConsolaInstance } from 'consola' +import type { RegistryScript } from '../../packages/script/src/runtime/types' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { validateScriptsEnvVars } from '../../packages/script/src/validate-env' + +const scripts = [ + { registryKey: 'clarity', envDefaults: { id: '' } }, + { registryKey: 'facebookPixel', envDefaults: { id: '' } }, + { registryKey: 'matomoAnalytics', envDefaults: { matomoUrl: '', siteId: '' } }, +] as unknown as RegistryScript[] + +function makeLogger() { + return { warn: vi.fn() } as unknown as ConsolaInstance +} + +describe('validateScriptsEnvVars', () => { + const removeKeys: string[] = [] + + beforeEach(() => { + for (const k of Object.keys(process.env)) { + if (k.startsWith('NUXT_PUBLIC_SCRIPTS_')) + delete process.env[k] + } + }) + + afterEach(() => { + for (const k of removeKeys.splice(0)) + delete process.env[k] + }) + + it('does not warn for valid env var on enabled script', () => { + process.env.NUXT_PUBLIC_SCRIPTS_CLARITY_ID = 'abc' + removeKeys.push('NUXT_PUBLIC_SCRIPTS_CLARITY_ID') + const logger = makeLogger() + validateScriptsEnvVars(scripts, new Set(['clarity']), logger) + expect(logger.warn).not.toHaveBeenCalled() + }) + + it('warns when env var uses marketing name instead of registry key', () => { + process.env.NUXT_PUBLIC_SCRIPTS_MICROSOFT_CLARITY_ID = 'abc' + removeKeys.push('NUXT_PUBLIC_SCRIPTS_MICROSOFT_CLARITY_ID') + const logger = makeLogger() + validateScriptsEnvVars(scripts, new Set(['clarity']), logger) + expect(logger.warn).toHaveBeenCalledTimes(1) + const msg = (logger.warn as any).mock.calls[0][0] as string + expect(msg).toContain('NUXT_PUBLIC_SCRIPTS_MICROSOFT_CLARITY_ID') + expect(msg).toContain('clarity') + }) + + it('warns when field is unknown on a valid key', () => { + process.env.NUXT_PUBLIC_SCRIPTS_CLARITY_FOO = 'x' + removeKeys.push('NUXT_PUBLIC_SCRIPTS_CLARITY_FOO') + const logger = makeLogger() + validateScriptsEnvVars(scripts, new Set(['clarity']), logger) + expect(logger.warn).toHaveBeenCalledTimes(1) + const msg = (logger.warn as any).mock.calls[0][0] as string + expect(msg).toContain('does not match any option on `clarity`') + expect(msg).toContain('NUXT_PUBLIC_SCRIPTS_CLARITY_ID') + }) + + it('warns when env var is set but script not registered', () => { + process.env.NUXT_PUBLIC_SCRIPTS_CLARITY_ID = 'abc' + removeKeys.push('NUXT_PUBLIC_SCRIPTS_CLARITY_ID') + const logger = makeLogger() + validateScriptsEnvVars(scripts, new Set(), logger) + expect(logger.warn).toHaveBeenCalledTimes(1) + const msg = (logger.warn as any).mock.calls[0][0] as string + expect(msg).toContain('is not registered') + expect(msg).toContain('clarity') + }) + + it('handles camelCase registry keys', () => { + process.env.NUXT_PUBLIC_SCRIPTS_FACEBOOK_PIXEL_ID = 'abc' + removeKeys.push('NUXT_PUBLIC_SCRIPTS_FACEBOOK_PIXEL_ID') + const logger = makeLogger() + validateScriptsEnvVars(scripts, new Set(['facebookPixel']), logger) + expect(logger.warn).not.toHaveBeenCalled() + }) + + it('handles multi-field scripts', () => { + process.env.NUXT_PUBLIC_SCRIPTS_MATOMO_ANALYTICS_SITE_ID = '1' + process.env.NUXT_PUBLIC_SCRIPTS_MATOMO_ANALYTICS_MATOMO_URL = 'https://x' + removeKeys.push('NUXT_PUBLIC_SCRIPTS_MATOMO_ANALYTICS_SITE_ID', 'NUXT_PUBLIC_SCRIPTS_MATOMO_ANALYTICS_MATOMO_URL') + const logger = makeLogger() + validateScriptsEnvVars(scripts, new Set(['matomoAnalytics']), logger) + expect(logger.warn).not.toHaveBeenCalled() + }) +})