eslint-plugin-chrome-extensions 2.9.1 → 2.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "eslint-plugin-chrome-extensions",
3
- "version": "2.9.1",
3
+ "version": "2.11.0",
4
4
  "description": "ESLint rules for Chrome extensions",
5
5
  "keywords": [
6
6
  "eslint",
@@ -61,7 +61,7 @@
61
61
  "test": "node tests/run"
62
62
  },
63
63
  "dependencies": {
64
- "globals": "^17.13.0",
64
+ "globals": "^17.14.0",
65
65
  "spdx-license-ids": "^3.0.24",
66
66
  "spdx-license-list": "^6.12.0"
67
67
  },
@@ -80,13 +80,13 @@
80
80
  "eslint-plugin-n": "^18.5.0",
81
81
  "eslint-plugin-no-secrets": "^2.3.3",
82
82
  "eslint-plugin-no-unsanitized": "^4.1.5",
83
- "eslint-plugin-package-json": "^1.12.0",
83
+ "eslint-plugin-package-json": "^1.14.0",
84
84
  "eslint-plugin-promise": "^7.3.0",
85
- "eslint-plugin-regexp": "^3.3.2",
85
+ "eslint-plugin-regexp": "^3.3.3",
86
86
  "eslint-plugin-security": "^4.2.1",
87
87
  "eslint-plugin-sonarjs": "^4.2.2",
88
88
  "eslint-plugin-woodpecker": "^1.1.2",
89
- "oxlint": "^1.90.0",
89
+ "oxlint": "^1.91.0",
90
90
  "typescript": "npm:@typescript/typescript6@^6.0.2",
91
91
  "typescript-latest": "npm:typescript@^7.0.2"
92
92
  },
package/src/data/i18n.js CHANGED
@@ -1,11 +1,18 @@
1
1
  export default {
2
2
  defaultScanExtensions: ['cjs', 'css', 'htm', 'html', 'js', 'json', 'jsx', 'md', 'mjs', 'svg', 'ts', 'tsx', 'txt'],
3
3
  locales: {
4
- supported: new Set([ // https://developer.chrome.com/docs/extensions/reference/api/i18n
5
- 'af', 'am', 'ar', 'bg', 'bn', 'ca', 'cs', 'da', 'de', 'el', 'en', 'en_AU', 'en_GB', 'en_IN', 'en_US', 'es',
6
- 'es_419', 'es_MX', 'es_US', 'et', 'fa', 'fi', 'fil', 'fr', 'fr_CA', 'fr_FR', 'gu', 'he', 'hi', 'hr', 'hu',
7
- 'id', 'it', 'ja', 'kn', 'ko', 'lt', 'lv', 'ml', 'mr', 'ms', 'nl', 'no', 'pl', 'pt_BR', 'pt_PT', 'ro', 'ru',
8
- 'sk', 'sl', 'sr', 'sv', 'sw', 'ta', 'te', 'th', 'tr', 'uk', 'vi', 'zh_CN', 'zh_TW'
4
+ supported: new Set([
5
+ // https://developer.chrome.com/docs/extensions/reference/api/i18n#locales
6
+ 'am', 'ar', 'bg', 'bn', 'ca', 'cs', 'da', 'de', 'el', 'en', 'en_AU', 'en_GB', 'en_US', 'es', 'es_419', 'et',
7
+ 'fa', 'fi', 'fil', 'fr', 'gu', 'he', 'hi', 'hr', 'hu', 'id', 'it', 'ja', 'kn', 'ko', 'lt', 'lv', 'ml', 'mr',
8
+ 'ms', 'nl', 'no', 'pl', 'pt_BR', 'pt_PT', 'ro', 'ru', 'sk', 'sl', 'sr', 'sv', 'sw', 'ta', 'te', 'th', 'tr',
9
+ 'uk', 'vi', 'zh_CN', 'zh_TW',
10
+ // https://searchfox.org/firefox-release/source/browser/locales/l10n.toml
11
+ 'ach', 'af', 'an', 'ast', 'az', 'be', 'bo', 'br', 'brx', 'bs', 'ca_valencia', 'cak', 'ckb', 'cy', 'dsb',
12
+ 'en_CA', 'eo', 'es_AR', 'es_CL', 'es_ES', 'es_MX', 'eu', 'ff', 'fur', 'fy_NL', 'ga_IE', 'gd', 'gl', 'gn',
13
+ 'gu_IN', 'hi_IN', 'hsb', 'hy_AM', 'hye', 'ia', 'is', 'ja_JP_mac', 'ka', 'kab', 'kk', 'km', 'lij', 'lo',
14
+ 'ltg', 'meh', 'mk', 'my', 'nb_NO', 'ne_NP', 'nn_NO', 'oc', 'pa_IN', 'rm', 'sat', 'sc', 'scn', 'sco', 'si',
15
+ 'skr', 'son', 'sq', 'sv_SE', 'szl', 'tg', 'tl', 'trs', 'ur', 'uz', 'wo', 'xh'
9
16
  ])
10
17
  },
11
18
  re: {
package/src/data/regex.js CHANGED
@@ -6,6 +6,7 @@ export default {
6
6
  manifest: /manifest\.json$/,
7
7
  script: /\.[cm]?[jt]sx?$/i
8
8
  },
9
+ i18n: { placeholder: /__MSG_.+?__/ },
9
10
  prefix: { at: /^@/, css: /^CSS/ },
10
11
  scheme: /^(?:([a-z][a-z0-9+.-]*):|\/\/)/i,
11
12
  url: /^https?:\/\//
@@ -50,22 +50,21 @@ export default {
50
50
  return {
51
51
  'Document:exit'(document) {
52
52
  const root = document.body ; if (root?.type != 'Object') return
53
- const members = root.members || root.properties || [],
54
- violation = findViolation(members, master) ?? (order.length && findViolation(members, order))
53
+ const members = root.members || root.properties || []
54
+ let keys = master, violation = findViolation(members, master)
55
+ if (!violation && order.length) { keys = order ; violation = findViolation(members, order) }
55
56
  if (!violation) return
56
57
  const [first, second] = violation
57
58
  context.report({
58
59
  node: second.member, messageId: 'wrongOrder',
59
60
  data: { first: second.key, second: first.key },
60
61
  fix(fixer) {
61
- const sourceCode = context.sourceCode,
62
- firstText = sourceCode.text.slice(first.member.range[0], first.member.range[1]),
63
- secondText = sourceCode.text.slice(second.member.range[0], second.member.range[1]),
64
- between = sourceCode.text.slice(first.member.range[1], second.member.range[0])
65
- return fixer.replaceTextRange(
66
- [first.member.range[0], second.member.range[1]],
67
- secondText + between + firstText
68
- )
62
+ const text = context.sourceCode.text,
63
+ ranks = new Map(keys.map((key, index) => [key, index])),
64
+ known = members.filter(member => ranks.has(keyOf(member))),
65
+ sorted = known.toSorted((a, b) => ranks.get(keyOf(a)) - ranks.get(keyOf(b)))
66
+ return known.map((member, idx) => // oxlint-disable-next-line security/detect-object-injection
67
+ fixer.replaceTextRange(member.range, text.slice(...sorted[idx].range)))
69
68
  }
70
69
  })
71
70
  }
@@ -11,7 +11,7 @@ export default {
11
11
  meta: {
12
12
  type: 'problem',
13
13
  docs: {
14
- description: 'Disallow invalid Chrome locale codes in `_locales/` folders and `default_locale`',
14
+ description: 'Disallow invalid locale codes in `_locales/` folders and `default_locale`',
15
15
  category: 'Possible Errors',
16
16
  recommended: true,
17
17
  manifest: true
@@ -35,7 +35,7 @@ export default {
35
35
  }],
36
36
  defaultOptions: [defaultOptions],
37
37
  messages: {
38
- invalidLocale: '`{{value}}` is not a Chrome-supported locale code.'
38
+ invalidLocale: '`{{value}}` is not a supported locale code.'
39
39
  }
40
40
  },
41
41
 
@@ -69,47 +69,66 @@ export default {
69
69
  { ignoreKeys, checkAllLocales } = { ...defaultOptions, ...context.options[0] },
70
70
  ignored = new Set(ignoreKeys),
71
71
  loc = { line: 1, column: 1 }
72
- if (hasLocales && typeof defaultLocale != 'string') {
73
- context.report({ loc, messageId: 'missingDefaultLocale' })
74
- return {}
75
- }
76
- if (typeof defaultLocale == 'string' && !hasLocales) {
77
- context.report({ loc, messageId: 'unexpectedDefaultLocale' })
78
- return {}
79
- }
80
- if (typeof defaultLocale != 'string' || !hasLocales) return {}
81
- const defaultMsgs = readMessages(path.join(localesDir, defaultLocale, 'messages.json'))
82
- if (!defaultMsgs) {
83
- context.report({ loc, messageId: 'missingDefaultLocaleFile', data: { locale: defaultLocale }})
84
- return {}
85
- }
86
- const localeDirs = readLocaleDirs(localesDir)
87
- if (checkAllLocales) {
88
- const allMsgs = new Map([[defaultLocale, defaultMsgs]])
72
+ function run(report) {
73
+ if (hasLocales && typeof defaultLocale != 'string')
74
+ return report('missingDefaultLocale', null, null)
75
+ if (typeof defaultLocale == 'string' && !hasLocales)
76
+ return report('unexpectedDefaultLocale', 'default_locale', null)
77
+ if (typeof defaultLocale != 'string' || !hasLocales) return
78
+ const defaultMsgs = readMessages(path.join(localesDir, defaultLocale, 'messages.json'))
79
+ if (!defaultMsgs)
80
+ return report('missingDefaultLocaleFile', 'default_locale', { locale: defaultLocale })
81
+ const localeDirs = readLocaleDirs(localesDir)
82
+ if (checkAllLocales) {
83
+ const allMsgs = new Map([[defaultLocale, defaultMsgs]])
84
+ for (const locale of localeDirs) {
85
+ if (locale == defaultLocale) continue
86
+ const msgs = readMessages(path.join(localesDir, locale, 'messages.json'))
87
+ if (msgs) allMsgs.set(locale, msgs)
88
+ }
89
+ const union = new Set()
90
+ for (const msgs of allMsgs.values())
91
+ for (const key of Object.keys(msgs))
92
+ if (!i18n.isPredefined(key) && !ignored.has(key)) union.add(key)
93
+ for (const [locale, msgs] of allMsgs)
94
+ for (const key of union)
95
+ if (!(key in msgs)) report('missingKeyInLocale', null, { key, locale })
96
+ return
97
+ }
89
98
  for (const locale of localeDirs) {
90
99
  if (locale == defaultLocale) continue
91
- const msgs = readMessages(path.join(localesDir, locale, 'messages.json'))
92
- if (msgs) allMsgs.set(locale, msgs)
100
+ const msgs = readMessages(path.join(localesDir, locale, 'messages.json')) ; if (!msgs) continue
101
+ for (const key of Object.keys(msgs)) {
102
+ if (i18n.isPredefined(key) || ignored.has(key)) continue
103
+ if (!(key in defaultMsgs))
104
+ report('missingKeyInDefault', null, { key, locale, defaultLocale })
105
+ }
93
106
  }
94
- const union = new Set()
95
- for (const msgs of allMsgs.values())
96
- for (const key of Object.keys(msgs))
97
- if (!i18n.isPredefined(key) && !ignored.has(key)) union.add(key)
98
- for (const [locale, msgs] of allMsgs)
99
- for (const key of union)
100
- if (!(key in msgs))
101
- context.report({ loc, messageId: 'missingKeyInLocale', data: { key, locale }})
107
+ }
108
+ if (!regex.filename.manifest.test(filename)) {
109
+ run((messageId, _key, data) =>
110
+ context.report({ loc, messageId, ...(data ? { data } : {}) }))
102
111
  return {}
103
112
  }
104
- for (const locale of localeDirs) {
105
- if (locale == defaultLocale) continue
106
- const msgs = readMessages(path.join(localesDir, locale, 'messages.json')) ; if (!msgs) continue
107
- for (const key of Object.keys(msgs)) {
108
- if (i18n.isPredefined(key) || ignored.has(key)) continue
109
- if (!(key in defaultMsgs))
110
- context.report({ loc, messageId: 'missingKeyInDefault', data: { key, locale, defaultLocale }})
113
+ return {
114
+ 'Document:exit'(document) {
115
+ const body = document.body ; if (body?.type != 'Object') return
116
+ const members = body.members || body.properties || [],
117
+ findMember = key => members.find(m => {
118
+ const k = m.name || m.key
119
+ return (k?.value ?? k?.name) === key
120
+ }),
121
+ defaultLocaleMember = findMember('default_locale')
122
+ run((messageId, anchorKey, data) => {
123
+ const targetNode = anchorKey == 'default_locale'
124
+ ? defaultLocaleMember || body // no key → point at body
125
+ : body
126
+ const reportObj = { messageId, ...(data ? { data } : {}) }
127
+ if (targetNode) reportObj.node = targetNode
128
+ else reportObj.loc = loc
129
+ context.report(reportObj)
130
+ })
111
131
  }
112
132
  }
113
- return {}
114
133
  }
115
134
  }
@@ -29,32 +29,36 @@ export default {
29
29
  }],
30
30
  defaultOptions: [defaultOptions],
31
31
  messages: {
32
- missingKey: '`{{key}}` is not in `_locales/{{locale}}/messages.json`.'
32
+ missingKey: '`{{key}}` is not in `_locales/{{locale}}/messages.json`.',
33
+ noMessagesFile: '`chrome.i18n` is used but `_locales/{{locale}}/messages.json` does not exist.'
33
34
  }
34
35
  },
35
36
 
36
37
  create(context) {
37
38
  const filename = context.filename ; if (!filename || filename == '<input>') return {}
38
- const manifest = extension.manifest.read(context) ; if (!manifest?.default_locale) return {}
39
+ const manifest = extension.manifest.read(context) ; if (!manifest) return {}
39
40
  const root = extension.findRoot(context) ; if (!root) return {}
40
- const i18n = i18nUtils.readDefaultLocaleMessages(manifest, root) ; if (!i18n) return {}
41
- const { ignoreKeys } = { ...defaultOptions, ...context.options[0] },
42
- ignored = new Set(ignoreKeys),
43
- isMissing = key => !i18nUtils.isPredefined(key) && !ignored.has(key) && !(key in i18n.messages),
44
- loc = { line: 1, column: 1 }
41
+ const i18n = i18nUtils.readDefaultLocaleMessages(manifest, root),
42
+ locale = manifest.default_locale ?? '<default_locale>',
43
+ { ignoreKeys } = { ...defaultOptions, ...context.options[0] },
44
+ ignored = new Set(ignoreKeys),
45
+ isMissing = key => !i18nUtils.isPredefined(key) && !ignored.has(key) && !(key in i18n.messages),
46
+ loc = { line: 1, column: 1 }
45
47
  if (regex.filename.script.test(filename))
46
48
  return {
47
49
  CallExpression(node) {
48
50
  if (node.callee.type != 'MemberExpression') return
49
51
  const path = nodeUtils.getMemberPath(node.callee) ; if (!i18nData.re.getMsg.path.test(path)) return
50
52
  const arg = node.arguments[0] ; if (!arg) return
53
+ if (!i18n)
54
+ return context.report({ node: arg, messageId: 'noMessagesFile', data: { locale }})
51
55
  let key
52
56
  if (arg.type == 'Literal' && typeof arg.value == 'string')
53
57
  key = arg.value
54
58
  else if (arg.type == 'TemplateLiteral' && arg.expressions.length === 0)
55
59
  key = arg.quasis[0].value.cooked
56
60
  if (key && isMissing(key))
57
- context.report({ loc, messageId: 'missingKey', data: { key, locale: i18n.locale }})
61
+ context.report({ node: arg, messageId: 'missingKey', data: { key, locale: i18n.locale }})
58
62
  }
59
63
  }
60
64
  if (!regex.filename.css.test(filename) && !regex.filename.manifest.test(filename))
@@ -65,6 +69,10 @@ export default {
65
69
  const key = match[1]
66
70
  if (seenKeys.has(key)) continue
67
71
  seenKeys.add(key)
72
+ if (!i18n) {
73
+ context.report({ loc, messageId: 'noMessagesFile', data: { locale }})
74
+ continue
75
+ }
68
76
  if (isMissing(key))
69
77
  context.report({ loc, messageId: 'missingKey', data: { key, locale: i18n.locale }})
70
78
  }
@@ -1,10 +1,11 @@
1
1
  import extension from '../utils/extension.js'
2
2
  import regex from '../data/regex.js'
3
3
 
4
- const defaultOptions = { maxNameLength: 12, always: false, checkRedundant: false },
5
- re = { i18nPlaceholder: /^__MSG_.+__$/ }
4
+ const defaultOptions = { maxNameLength: 12, always: false, checkRedundant: false }
6
5
 
7
- function isI18n(value) { return typeof value == 'string' && re.i18nPlaceholder.test(value) }
6
+ function findMember(members, key) { return members.find(member => keyOf(member) === key) }
7
+ function getMinLength(name) { return typeof name == 'string' ? name.split(regex.i18n.placeholder).join('x').length : 0 }
8
+ function keyOf(member) { const keyNode = member.name || member.key ; return keyNode?.value ?? keyNode?.name }
8
9
 
9
10
  export default {
10
11
  meta: {
@@ -35,7 +36,7 @@ export default {
35
36
  defaultOptions: [defaultOptions],
36
37
  messages: {
37
38
  missingShortName: '`name` exceeds {{max}} chars. Declare `short_name` to control truncation.',
38
- shortNameTooLong: '`short_name` exceeds the recommended {{max}}-character limit (found {{length}}).',
39
+ shortNameTooLong: '`short_name` exceeds the recommended {{max}}-character limit (found at least {{length}}).',
39
40
  redundantShortName: '`short_name` duplicates `name` (Chrome falls back to `name` automatically).'
40
41
  }
41
42
  },
@@ -43,24 +44,27 @@ export default {
43
44
  create(context) {
44
45
  if (!regex.filename.manifest.test(extension.file.getName(context))) return {}
45
46
  const { maxNameLength, always, checkRedundant } = { ...defaultOptions, ...context.options[0] }
46
- let manifest
47
- try { manifest = JSON.parse(context.sourceCode.text) } catch { return {} }
48
- if (!manifest || typeof manifest != 'object') return {}
49
- const name = manifest.name, shortName = manifest.short_name
50
- if (typeof name != 'string') return {}
51
- const nameIsI18n = isI18n(name),
52
- shortNameIsI18n = isI18n(shortName),
53
- loc = { line: 1, column: 1 }
54
- if (typeof shortName != 'string') {
55
- if (always || (!nameIsI18n && name.length > maxNameLength))
56
- context.report({ loc, messageId: 'missingShortName', data: { max: maxNameLength }})
57
- } else {
58
- if (!shortNameIsI18n && shortName.length > maxNameLength)
59
- context.report({ loc, messageId: 'shortNameTooLong',
60
- data: { max: maxNameLength, length: shortName.length }})
61
- if (checkRedundant && shortName == name)
62
- context.report({ loc, messageId: 'redundantShortName' })
47
+ return {
48
+ 'Document:exit'(document) {
49
+ const root = document.body ; if (root?.type != 'Object') return
50
+ const members = root.members || root.properties || [],
51
+ nameMember = findMember(members, 'name'),
52
+ shortNameMember = findMember(members, 'short_name'),
53
+ name = nameMember?.value?.value,
54
+ shortName = shortNameMember?.value?.value
55
+ if (typeof name != 'string') return
56
+ const nameLength = getMinLength(name), shortNameLength = getMinLength(shortName)
57
+ if (typeof shortName != 'string') {
58
+ if (always || nameLength > maxNameLength)
59
+ context.report({ node: nameMember, messageId: 'missingShortName', data: { max: maxNameLength }})
60
+ } else {
61
+ if (shortNameLength > maxNameLength)
62
+ context.report({ node: shortNameMember, messageId: 'shortNameTooLong',
63
+ data: { max: maxNameLength, length: shortNameLength }})
64
+ if (checkRedundant && shortName == name)
65
+ context.report({ node: shortNameMember, messageId: 'redundantShortName' })
66
+ }
67
+ }
63
68
  }
64
- return {}
65
69
  }
66
70
  }
@@ -25,23 +25,52 @@ function collectRefs(manifest) {
25
25
  const refs = []
26
26
  for (const [label, get] of refKeys) {
27
27
  const val = get(manifest)
28
- if (typeof val == 'string') refs.push({ label, file: val })
28
+ if (typeof val == 'string') refs.push({ label, pathArr: label.split('.'), file: val })
29
29
  }
30
30
  if (manifest.background?.scripts)
31
- refs.push(...manifest.background.scripts.map(file => ({ label: 'background.scripts', file })))
31
+ manifest.background.scripts.forEach((file, i) =>
32
+ refs.push({ label: 'background.scripts', pathArr: ['background', 'scripts', i], file }))
32
33
  if (manifest.icons)
33
- refs.push(...Object.values(manifest.icons).map(file => ({ label: 'icons', file })))
34
+ Object.entries(manifest.icons).forEach(([k, file]) =>
35
+ refs.push({ label: 'icons', pathArr: ['icons', k], file }))
34
36
  if (manifest.action?.default_icon && typeof manifest.action.default_icon == 'object')
35
- refs.push(...Object.values(manifest.action.default_icon).map(file => ({ label: 'action.default_icon', file })))
36
- for (const cs of manifest.content_scripts || []) {
37
- if (cs.js) refs.push(...cs.js.map(file => ({ label: 'content_scripts.js', file })))
38
- if (cs.css) refs.push(...cs.css.map(file => ({ label: 'content_scripts.css', file })))
37
+ Object.entries(manifest.action.default_icon).forEach(([k, file]) =>
38
+ refs.push({ label: 'action.default_icon', pathArr: ['action', 'default_icon', k], file }))
39
+ for (const [ci, cs] of (manifest.content_scripts || []).entries()) {
40
+ if (cs.js) cs.js.forEach((file, i) =>
41
+ refs.push({ label: 'content_scripts.js', pathArr: ['content_scripts', ci, 'js', i], file }))
42
+ if (cs.css) cs.css.forEach((file, i) =>
43
+ refs.push({ label: 'content_scripts.css', pathArr: ['content_scripts', ci, 'css', i], file }))
39
44
  }
40
- for (const war of manifest.web_accessible_resources || [])
41
- if (war.resources) refs.push(...war.resources.map(file => ({ label: 'web_accessible_resources', file })))
45
+ for (const [wi, war] of (manifest.web_accessible_resources || []).entries())
46
+ if (war.resources) war.resources.forEach((file, i) =>
47
+ refs.push({ label: 'web_accessible_resources', pathArr: ['web_accessible_resources', wi, 'resources', i], file }))
42
48
  return refs
43
49
  }
44
50
 
51
+ function getNodeAt(root, pathArr) {
52
+ let node = root
53
+ for (const seg of pathArr) {
54
+ if (node == null) return null
55
+ if (typeof seg == 'number') {
56
+ if (node.type != 'Array') return null
57
+ const elem = (node.elements || node.items || [])[seg]
58
+ if (elem == null) return null
59
+ // @eslint/json wraps array elements in `{ value: <node> }`; unwrap
60
+ node = (typeof elem.value == 'object' && elem.value !== null) ? elem.value : elem
61
+ } else {
62
+ if (node.type != 'Object') return null
63
+ const member = (node.members || node.properties || []).find(m => {
64
+ const k = m.name || m.key
65
+ return (k?.value ?? k?.name) === seg
66
+ })
67
+ if (!member) return null
68
+ node = member.value
69
+ }
70
+ }
71
+ return node
72
+ }
73
+
45
74
  function resolveRef(abs) {
46
75
  const dir = path.dirname(abs),
47
76
  base = path.basename(abs)
@@ -122,22 +151,34 @@ export default {
122
151
  data: { length: filename.length, max: maxFullPathLength }
123
152
  })
124
153
  if (basename != 'manifest.json' || (!checkFileExists && !enforceCaseSensitivity)) return {}
125
- const manifestPath = path.join(extRoot, 'manifest.json')
126
- if (!fs.existsSync(manifestPath)) return {}
127
- let manifest ; try { manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8')) } catch { return {} }
128
- for (const { label, file } of collectRefs(manifest)) {
129
- if (file.includes('*')) continue // wildcard refs can't be verified
130
- const abs = path.resolve(extRoot, file),
131
- result = resolveRef(abs)
132
- if (!result
133
- || result.id == 'fileMissing' && !checkFileExists
134
- || result.id == 'caseMismatch' && !enforceCaseSensitivity
135
- ) continue
136
- const data = result.id == 'caseMismatch'
137
- ? { label, file, actual: result.actual }
138
- : { label, file }
139
- context.report({ loc: { line: 1, column: 1 }, messageId: result.id, data })
154
+ return {
155
+ 'Document:exit'(document) {
156
+ const root = document.body ; if (root?.type != 'Object') return
157
+ const manifestPath = path.join(extRoot, 'manifest.json')
158
+ let manifest
159
+ try {
160
+ manifest = fs.existsSync(manifestPath)
161
+ ? JSON.parse(fs.readFileSync(manifestPath, 'utf8'))
162
+ : JSON.parse(context.sourceCode.text)
163
+ } catch { return }
164
+ for (const { label, pathArr, file } of collectRefs(manifest)) {
165
+ if (file.includes('*')) continue // wildcard refs can't be verified
166
+ const abs = path.resolve(extRoot, file),
167
+ result = resolveRef(abs)
168
+ if (!result
169
+ || result.id == 'fileMissing' && !checkFileExists
170
+ || result.id == 'caseMismatch' && !enforceCaseSensitivity
171
+ ) continue
172
+ const data = result.id == 'caseMismatch'
173
+ ? { label, file, actual: result.actual }
174
+ : { label, file }
175
+ const node = getNodeAt(root, pathArr)
176
+ context.report({
177
+ ...(node ? { node } : { loc: { line: 1, column: 1 } }),
178
+ messageId: result.id, data
179
+ })
180
+ }
181
+ }
140
182
  }
141
- return {}
142
183
  }
143
184
  }
@@ -16,44 +16,64 @@ function collectStrings(obj, path = [], out = []) {
16
16
  if (obj == null) return out
17
17
  if (typeof obj == 'string') out.push({ path: path.join('.'), value: obj })
18
18
  else if (Array.isArray(obj))
19
- for (const elem of obj) collectStrings(elem, path, out)
19
+ for (const element of obj) collectStrings(element, path, out)
20
20
  else if (typeof obj == 'object')
21
21
  for (const key of Object.keys(obj)) // oxlint-disable-next-line security/detect-object-injection
22
22
  collectStrings(obj[key], [...path, key], out)
23
23
  return out
24
24
  }
25
25
 
26
- function isURL(value) { return re.absoluteURL.test(value.replace(re.template, 'x')) }
26
+ function collectNodes(node, pathArray = [], out = []) {
27
+ if (node?.type == 'Object') {
28
+ for (const member of node.members || node.properties || []) {
29
+ const keyNode = member.name || member.key
30
+ const key = keyNode?.value ?? keyNode?.name
31
+ if (typeof key != 'string') continue
32
+ collectNodes(member.value, [...pathArray, key], out)
33
+ }
34
+ } else if (node?.type == 'Array') {
35
+ for (const element of node.elements || node.items || []) {
36
+ if (element == null) continue
37
+ // @eslint/json wraps array elements in `{ value: <node> }`; unwrap
38
+ collectNodes(element.value ?? element, pathArray, out)
39
+ }
40
+ } else if (node?.type == 'String') {
41
+ out.push({ path: pathArray.join('.'), value: node.value, node })
42
+ }
43
+ return out
44
+ }
27
45
 
28
- function validateManifest(context, manifest, opts) {
29
- const { checkURLfields, checkMatchPatterns, allowedSchemes, allowAllURLs } = opts,
30
- loc = { line: 1, column: 1 }
31
- for (const { path, value } of collectStrings(manifest)) {
46
+ function isURL(value) {
47
+ return value.match(regex.i18n.placeholder)?.[0] === value
48
+ || re.absoluteURL.test(value.replace(re.template, 'x'))
49
+ }
50
+
51
+ function validate(context, items, options) {
52
+ const { checkURLfields, checkMatchPatterns, allowedSchemes, allowAllURLs } = options,
53
+ loc = { line: 1, column: 1 }
54
+ function report(node, messageId, data) {
55
+ context.report({ ...(node ? { node } : { loc }), messageId, data }) }
56
+ for (const { path, value, node } of items) {
32
57
  if (checkURLfields && manifestData.paths.url.has(path)) {
33
- if (!isURL(value))
34
- context.report({ loc, messageId: 'invalidURL', data: { field: path, value }})
58
+ if (!isURL(value)) report(node, 'invalidURL', { field: path, value })
35
59
  } else if (checkMatchPatterns && manifestData.paths.match.has(path)) {
36
60
  if (value == '<all_urls>') {
37
- if (!allowAllURLs)
38
- context.report({ loc, messageId: 'allURLs', data: { field: path }})
61
+ if (!allowAllURLs) report(node, 'allURLs', { field: path })
39
62
  continue
40
63
  }
41
64
  const match = value.match(re.match)
42
- if (!match) {
43
- context.report({ loc, messageId: 'invalidMatchPattern', data: { field: path, value }})
44
- continue
45
- }
65
+ if (!match) { report(node, 'invalidMatchPattern', { field: path, value }) ; continue }
46
66
  const [, scheme, host] = match
47
67
  if (scheme != '*' && !allowedSchemes.includes(scheme))
48
- context.report({ loc, messageId: 'invalidScheme', data: { field: path, value }})
68
+ report(node, 'invalidScheme', { field: path, value })
49
69
  else if (scheme == 'file' && host != '')
50
- context.report({ loc, messageId: 'invalidFileHost', data: { field: path, value }})
70
+ report(node, 'invalidFileHost', { field: path, value })
51
71
  else if (path == 'externally_connectable.matches' && host.includes('*'))
52
- context.report({ loc, messageId: 'invalidExternalHost', data: { field: path, value }})
72
+ report(node, 'invalidExternalHost', { field: path, value })
53
73
  else if (host.includes('*') && host != '*' && !host.startsWith('*.'))
54
- context.report({ loc, messageId: 'invalidHost', data: { field: path, value }})
74
+ report(node, 'invalidHost', { field: path, value })
55
75
  else if (path == 'web_accessible_resources.matches' && !value.endsWith('/*'))
56
- context.report({ loc, messageId: 'invalidWARPath', data: { field: path, value }})
76
+ report(node, 'invalidWARPath', { field: path, value })
57
77
  }
58
78
  }
59
79
  }
@@ -109,14 +129,12 @@ export default {
109
129
  if (!options.checkURLfields && !options.checkMatchPatterns) return {}
110
130
  if (!regex.filename.manifest.test(filename)) {
111
131
  const manifest = extension.manifest.read(context) ; if (!manifest) return {}
112
- validateManifest(context, manifest, options)
113
- return {}
132
+ return { 'Program:exit'() { validate(context, collectStrings(manifest), options) }}
114
133
  }
115
134
  return {
116
- 'Document:exit'() {
117
- let manifest
118
- try { manifest = JSON.parse(context.sourceCode.text) } catch { return }
119
- validateManifest(context, manifest, options)
135
+ 'Document:exit'(document) {
136
+ const body = document.body ; if (body?.type != 'Object') return
137
+ validate(context, collectNodes(body), options)
120
138
  }
121
139
  }
122
140
  }