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 +5 -5
- package/src/data/i18n.js +12 -5
- package/src/data/regex.js +1 -0
- package/src/rules/manifest-key-order.js +9 -10
- package/src/rules/no-invalid-locales.js +2 -2
- package/src/rules/no-missing-default-locale.js +54 -35
- package/src/rules/no-missing-i18n-keys.js +16 -8
- package/src/rules/require-short-name-when-useful.js +26 -22
- package/src/rules/valid-filenames.js +66 -25
- package/src/rules/valid-url-fields.js +43 -25
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "eslint-plugin-chrome-extensions",
|
|
3
|
-
"version": "2.
|
|
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.
|
|
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.
|
|
83
|
+
"eslint-plugin-package-json": "^1.14.0",
|
|
84
84
|
"eslint-plugin-promise": "^7.3.0",
|
|
85
|
-
"eslint-plugin-regexp": "^3.3.
|
|
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.
|
|
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([
|
|
5
|
-
|
|
6
|
-
'
|
|
7
|
-
'
|
|
8
|
-
'
|
|
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
|
@@ -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
|
-
|
|
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
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
return
|
|
66
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
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
|
-
|
|
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
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
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
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
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
|
|
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)
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
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({
|
|
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
|
|
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
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
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
|
-
|
|
31
|
+
manifest.background.scripts.forEach((file, i) =>
|
|
32
|
+
refs.push({ label: 'background.scripts', pathArr: ['background', 'scripts', i], file }))
|
|
32
33
|
if (manifest.icons)
|
|
33
|
-
|
|
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
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
if (cs.
|
|
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)
|
|
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
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
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
|
|
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
|
|
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
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
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
|
-
|
|
68
|
+
report(node, 'invalidScheme', { field: path, value })
|
|
49
69
|
else if (scheme == 'file' && host != '')
|
|
50
|
-
|
|
70
|
+
report(node, 'invalidFileHost', { field: path, value })
|
|
51
71
|
else if (path == 'externally_connectable.matches' && host.includes('*'))
|
|
52
|
-
|
|
72
|
+
report(node, 'invalidExternalHost', { field: path, value })
|
|
53
73
|
else if (host.includes('*') && host != '*' && !host.startsWith('*.'))
|
|
54
|
-
|
|
74
|
+
report(node, 'invalidHost', { field: path, value })
|
|
55
75
|
else if (path == 'web_accessible_resources.matches' && !value.endsWith('/*'))
|
|
56
|
-
|
|
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
|
-
|
|
113
|
-
return {}
|
|
132
|
+
return { 'Program:exit'() { validate(context, collectStrings(manifest), options) }}
|
|
114
133
|
}
|
|
115
134
|
return {
|
|
116
|
-
'Document:exit'() {
|
|
117
|
-
|
|
118
|
-
|
|
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
|
}
|