Skip to content

Commit 05d3250

Browse files
authored
feat: warn on misconfigured NUXT_PUBLIC_SCRIPTS_* env vars (#778)
1 parent fb50b0a commit 05d3250

3 files changed

Lines changed: 260 additions & 0 deletions

File tree

packages/script/src/module.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@ import { generateInterceptPluginContents } from './plugins/intercept'
4040
import { NuxtScriptBundleTransformer } from './plugins/transform'
4141
import { buildProxyConfigsFromRegistry, generatePartytownResolveUrl, getPartytownForwards, registry, resolveCapabilities } from './registry'
4242
import { registerTypeTemplates, templatePlugin, templateTriggerResolver } from './templates'
43+
import { validateScriptsEnvVars } from './validate-env'
4344

4445
export type { FirstPartyPrivacy }
4546

@@ -556,6 +557,14 @@ export default defineNuxtModule<ModuleOptions>({
556557
)
557558
}
558559

560+
// Surface env vars under `NUXT_PUBLIC_SCRIPTS_*` that won't be consumed:
561+
// wrong key (typo / marketing name), wrong field, or script not registered.
562+
validateScriptsEnvVars(
563+
scripts,
564+
new Set(Object.keys(config.registry || {}).filter(k => (config.registry as any)?.[k] !== false)),
565+
logger,
566+
)
567+
559568
// Setup runtimeConfig for proxies and devtools.
560569
// Must run AFTER env var resolution above so the API key is populated.
561570
const googleMapsEnabled = config.googleStaticMapsProxy?.enabled || !!config.registry?.googleMaps
Lines changed: 163 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,163 @@
1+
import type { ConsolaInstance } from 'consola'
2+
import type { RegistryScript } from './runtime/types'
3+
4+
const UPPER_RE = /([A-Z])/g
5+
const toScreamingSnake = (s: string) => s.replace(UPPER_RE, '_$1').toUpperCase()
6+
7+
const ENV_PREFIX = 'NUXT_PUBLIC_SCRIPTS_'
8+
9+
function levenshtein(a: string, b: string): number {
10+
if (a === b)
11+
return 0
12+
if (!a.length)
13+
return b.length
14+
if (!b.length)
15+
return a.length
16+
const prev: number[] = []
17+
for (let j = 0; j <= b.length; j++) prev.push(j)
18+
for (let i = 1; i <= a.length; i++) {
19+
let prevDiag = prev[0]!
20+
prev[0] = i
21+
for (let j = 1; j <= b.length; j++) {
22+
const tmp = prev[j]!
23+
prev[j] = a[i - 1] === b[j - 1]
24+
? prevDiag
25+
: Math.min(prevDiag, prev[j]!, prev[j - 1]!) + 1
26+
prevDiag = tmp
27+
}
28+
}
29+
return prev[b.length]!
30+
}
31+
32+
/**
33+
* Warn for `NUXT_PUBLIC_SCRIPTS_*` env vars that don't map to a valid registry
34+
* key + field. Nuxt resolves env vars against runtimeConfig before modules run,
35+
* so a misspelled key (e.g. `NUXT_PUBLIC_SCRIPTS_MICROSOFT_CLARITY_ID` instead
36+
* of `NUXT_PUBLIC_SCRIPTS_CLARITY_ID`) is silently dropped with no error.
37+
*/
38+
export function validateScriptsEnvVars(
39+
scripts: RegistryScript[],
40+
enabledRegistryKeys: Set<string>,
41+
logger: ConsolaInstance,
42+
): void {
43+
// Build a map from screaming-snake registry key to its envDefaults fields
44+
const validByKey = new Map<string, { camel: string, fields: Set<string> }>()
45+
for (const s of scripts) {
46+
if (!s.registryKey || !s.envDefaults || !Object.keys(s.envDefaults).length)
47+
continue
48+
const screaming = toScreamingSnake(s.registryKey)
49+
const fields = new Set(Object.keys(s.envDefaults).map(toScreamingSnake))
50+
validByKey.set(screaming, { camel: s.registryKey, fields })
51+
}
52+
53+
if (!validByKey.size)
54+
return
55+
56+
const allValidEnvKeys: string[] = []
57+
for (const [screaming, { fields }] of validByKey) {
58+
for (const f of fields)
59+
allValidEnvKeys.push(`${ENV_PREFIX}${screaming}_${f}`)
60+
}
61+
62+
for (const envKey of Object.keys(process.env)) {
63+
if (!envKey.startsWith(ENV_PREFIX))
64+
continue
65+
if (allValidEnvKeys.includes(envKey))
66+
continue
67+
68+
const segment = envKey.slice(ENV_PREFIX.length)
69+
70+
// Case 1: matches a valid registry key but unknown field
71+
let matchedKey: { screaming: string, camel: string, fields: Set<string> } | undefined
72+
for (const [screaming, info] of validByKey) {
73+
if (segment === screaming || segment.startsWith(`${screaming}_`)) {
74+
matchedKey = { screaming, ...info }
75+
break
76+
}
77+
}
78+
79+
if (matchedKey) {
80+
const field = segment.slice(matchedKey.screaming.length + 1)
81+
logger.warn(
82+
`[scripts] env var \`${envKey}\` does not match any option on \`${matchedKey.camel}\`. `
83+
+ `Valid fields: ${[...matchedKey.fields].map(f => `\`${ENV_PREFIX}${matchedKey!.screaming}_${f}\``).join(', ')}.${
84+
field ? ` Got: \`${field}\`.` : ''}`,
85+
)
86+
continue
87+
}
88+
89+
// Case 2: registry key appears as a substring of the segment (e.g.
90+
// `MICROSOFT_CLARITY_ID` contains `CLARITY`). Likely a marketing-name
91+
// prefix; suggest the canonical key, and if the remainder is a valid
92+
// field, suggest the full corrected env var.
93+
const segmentParts = segment.split('_')
94+
let substringMatch: { screaming: string, camel: string, fields: Set<string>, remainder: string } | undefined
95+
for (const [screaming, info] of validByKey) {
96+
const keyParts = screaming.split('_')
97+
for (let i = 0; i <= segmentParts.length - keyParts.length; i++) {
98+
let ok = true
99+
for (let j = 0; j < keyParts.length; j++) {
100+
if (segmentParts[i + j] !== keyParts[j]) {
101+
ok = false
102+
break
103+
}
104+
}
105+
if (ok) {
106+
substringMatch = {
107+
screaming,
108+
camel: info.camel,
109+
fields: info.fields,
110+
remainder: segmentParts.slice(i + keyParts.length).join('_'),
111+
}
112+
break
113+
}
114+
}
115+
if (substringMatch)
116+
break
117+
}
118+
119+
let suggestion = ''
120+
if (substringMatch) {
121+
if (substringMatch.remainder && substringMatch.fields.has(substringMatch.remainder)) {
122+
suggestion = ` Did you mean \`${ENV_PREFIX}${substringMatch.screaming}_${substringMatch.remainder}\` (registry key \`${substringMatch.camel}\`)?`
123+
}
124+
else {
125+
suggestion = ` Did you mean registry key \`${substringMatch.camel}\` (\`${ENV_PREFIX}${substringMatch.screaming}_*\`)?`
126+
}
127+
}
128+
else {
129+
// Fallback: closest registry key by edit distance on the leading parts
130+
let best: { key: string, camel: string, dist: number } | undefined
131+
for (const [screaming, info] of validByKey) {
132+
const head = segmentParts.slice(0, screaming.split('_').length).join('_')
133+
const d = levenshtein(head, screaming)
134+
if (!best || d < best.dist)
135+
best = { key: screaming, camel: info.camel, dist: d }
136+
}
137+
if (best && best.dist <= Math.max(2, Math.floor(best.key.length / 2)))
138+
suggestion = ` Did you mean registry key \`${best.camel}\` (\`${ENV_PREFIX}${best.key}_*\`)?`
139+
}
140+
141+
logger.warn(
142+
`[scripts] env var \`${envKey}\` does not map to any registered script.${
143+
suggestion}`,
144+
)
145+
}
146+
147+
// Case 3: env var maps to a known script that the user hasn't enabled in
148+
// their nuxt.config registry. The value gets resolved into runtimeConfig
149+
// but won't be consumed unless the script is registered.
150+
for (const [screaming, info] of validByKey) {
151+
if (enabledRegistryKeys.has(info.camel))
152+
continue
153+
for (const field of info.fields) {
154+
const envKey = `${ENV_PREFIX}${screaming}_${field}`
155+
if (process.env[envKey] !== undefined) {
156+
logger.warn(
157+
`[scripts] env var \`${envKey}\` is set but \`${info.camel}\` is not registered in \`scripts.registry\`. `
158+
+ `Add \`registry: { ${info.camel}: {} }\` to your nuxt.config for it to take effect.`,
159+
)
160+
}
161+
}
162+
}
163+
}

test/unit/validate-env.test.ts

Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
import type { ConsolaInstance } from 'consola'
2+
import type { RegistryScript } from '../../packages/script/src/runtime/types'
3+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
4+
import { validateScriptsEnvVars } from '../../packages/script/src/validate-env'
5+
6+
const scripts = [
7+
{ registryKey: 'clarity', envDefaults: { id: '' } },
8+
{ registryKey: 'facebookPixel', envDefaults: { id: '' } },
9+
{ registryKey: 'matomoAnalytics', envDefaults: { matomoUrl: '', siteId: '' } },
10+
] as unknown as RegistryScript[]
11+
12+
function makeLogger() {
13+
return { warn: vi.fn() } as unknown as ConsolaInstance
14+
}
15+
16+
describe('validateScriptsEnvVars', () => {
17+
const removeKeys: string[] = []
18+
19+
beforeEach(() => {
20+
for (const k of Object.keys(process.env)) {
21+
if (k.startsWith('NUXT_PUBLIC_SCRIPTS_'))
22+
delete process.env[k]
23+
}
24+
})
25+
26+
afterEach(() => {
27+
for (const k of removeKeys.splice(0))
28+
delete process.env[k]
29+
})
30+
31+
it('does not warn for valid env var on enabled script', () => {
32+
process.env.NUXT_PUBLIC_SCRIPTS_CLARITY_ID = 'abc'
33+
removeKeys.push('NUXT_PUBLIC_SCRIPTS_CLARITY_ID')
34+
const logger = makeLogger()
35+
validateScriptsEnvVars(scripts, new Set(['clarity']), logger)
36+
expect(logger.warn).not.toHaveBeenCalled()
37+
})
38+
39+
it('warns when env var uses marketing name instead of registry key', () => {
40+
process.env.NUXT_PUBLIC_SCRIPTS_MICROSOFT_CLARITY_ID = 'abc'
41+
removeKeys.push('NUXT_PUBLIC_SCRIPTS_MICROSOFT_CLARITY_ID')
42+
const logger = makeLogger()
43+
validateScriptsEnvVars(scripts, new Set(['clarity']), logger)
44+
expect(logger.warn).toHaveBeenCalledTimes(1)
45+
const msg = (logger.warn as any).mock.calls[0][0] as string
46+
expect(msg).toContain('NUXT_PUBLIC_SCRIPTS_MICROSOFT_CLARITY_ID')
47+
expect(msg).toContain('clarity')
48+
})
49+
50+
it('warns when field is unknown on a valid key', () => {
51+
process.env.NUXT_PUBLIC_SCRIPTS_CLARITY_FOO = 'x'
52+
removeKeys.push('NUXT_PUBLIC_SCRIPTS_CLARITY_FOO')
53+
const logger = makeLogger()
54+
validateScriptsEnvVars(scripts, new Set(['clarity']), logger)
55+
expect(logger.warn).toHaveBeenCalledTimes(1)
56+
const msg = (logger.warn as any).mock.calls[0][0] as string
57+
expect(msg).toContain('does not match any option on `clarity`')
58+
expect(msg).toContain('NUXT_PUBLIC_SCRIPTS_CLARITY_ID')
59+
})
60+
61+
it('warns when env var is set but script not registered', () => {
62+
process.env.NUXT_PUBLIC_SCRIPTS_CLARITY_ID = 'abc'
63+
removeKeys.push('NUXT_PUBLIC_SCRIPTS_CLARITY_ID')
64+
const logger = makeLogger()
65+
validateScriptsEnvVars(scripts, new Set(), logger)
66+
expect(logger.warn).toHaveBeenCalledTimes(1)
67+
const msg = (logger.warn as any).mock.calls[0][0] as string
68+
expect(msg).toContain('is not registered')
69+
expect(msg).toContain('clarity')
70+
})
71+
72+
it('handles camelCase registry keys', () => {
73+
process.env.NUXT_PUBLIC_SCRIPTS_FACEBOOK_PIXEL_ID = 'abc'
74+
removeKeys.push('NUXT_PUBLIC_SCRIPTS_FACEBOOK_PIXEL_ID')
75+
const logger = makeLogger()
76+
validateScriptsEnvVars(scripts, new Set(['facebookPixel']), logger)
77+
expect(logger.warn).not.toHaveBeenCalled()
78+
})
79+
80+
it('handles multi-field scripts', () => {
81+
process.env.NUXT_PUBLIC_SCRIPTS_MATOMO_ANALYTICS_SITE_ID = '1'
82+
process.env.NUXT_PUBLIC_SCRIPTS_MATOMO_ANALYTICS_MATOMO_URL = 'https://x'
83+
removeKeys.push('NUXT_PUBLIC_SCRIPTS_MATOMO_ANALYTICS_SITE_ID', 'NUXT_PUBLIC_SCRIPTS_MATOMO_ANALYTICS_MATOMO_URL')
84+
const logger = makeLogger()
85+
validateScriptsEnvVars(scripts, new Set(['matomoAnalytics']), logger)
86+
expect(logger.warn).not.toHaveBeenCalled()
87+
})
88+
})

0 commit comments

Comments
 (0)