Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions packages/script/src/module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 }

Expand Down Expand Up @@ -556,6 +557,14 @@ export default defineNuxtModule<ModuleOptions>({
)
}

// 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
Expand Down
163 changes: 163 additions & 0 deletions packages/script/src/validate-env.ts
Original file line number Diff line number Diff line change
@@ -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<string>,
logger: ConsolaInstance,
): void {
// Build a map from screaming-snake registry key to its envDefaults fields
const validByKey = new Map<string, { camel: string, fields: Set<string> }>()
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<string> } | 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<string>, 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.`,
)
}
}
}
}
88 changes: 88 additions & 0 deletions test/unit/validate-env.test.ts
Original file line number Diff line number Diff line change
@@ -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()
})
})
Loading