@unhead/eslint-plugin 3.0.5
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/LICENSE +21 -0
- package/README.md +53 -0
- package/dist/index.d.mts +23 -0
- package/dist/index.d.ts +23 -0
- package/dist/index.mjs +540 -0
- package/package.json +60 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) Harlan Wilton <harlan@harlanzw.com>
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
# `@unhead/eslint-plugin`
|
|
2
|
+
|
|
3
|
+
ESLint rules that catch unhead misuse, type-narrowing issues, and v2-to-v3 migration problems at the source level. Pairs with the runtime `ValidatePlugin` and the `unhead` CLI to give you static and runtime coverage of the same rule set.
|
|
4
|
+
|
|
5
|
+
## Install
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
pnpm add -D @unhead/eslint-plugin eslint
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
## Usage (flat config)
|
|
12
|
+
|
|
13
|
+
```ts
|
|
14
|
+
// eslint.config.ts
|
|
15
|
+
import { configs } from '@unhead/eslint-plugin'
|
|
16
|
+
|
|
17
|
+
export default [
|
|
18
|
+
configs.recommended,
|
|
19
|
+
]
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
For projects migrating from unhead v2, swap in `configs.migration` to also wrap tag literals in the `defineLink` / `defineScript` helpers.
|
|
23
|
+
|
|
24
|
+
> Don't want to wire ESLint? `@unhead/cli` ships the same rules as a standalone command (`unhead audit` / `unhead migrate`) running on a native oxc parser — no parser configuration, works out-of-the-box on `.ts` / `.tsx` / `.vue` / `.svelte`.
|
|
25
|
+
|
|
26
|
+
## Rules
|
|
27
|
+
|
|
28
|
+
| Rule | Default | Autofix | What it catches |
|
|
29
|
+
|---|---|---|---|
|
|
30
|
+
| `defer-on-module-script` | warn | ✓ | `<script type="module" defer>` (defer is redundant) |
|
|
31
|
+
| `empty-meta-content` | warn | | `<meta name="description" content="">` |
|
|
32
|
+
| `no-deprecated-props` | error | ✓ | v2 props: `children`, `hid`, `vmid`, `body: true` |
|
|
33
|
+
| `no-html-in-title` | warn | | HTML chars in `title` (will be escaped, not rendered) |
|
|
34
|
+
| `no-unknown-meta` | warn | ✓ | typos in `name` / `property` (Levenshtein-suggested fix) |
|
|
35
|
+
| `non-absolute-canonical` | warn | | relative URLs in `<link rel="canonical">` |
|
|
36
|
+
| `numeric-tag-priority` | warn | suggestion | numeric `tagPriority` (suggests `'critical'`, `'high'`, or `'low'`) |
|
|
37
|
+
| `prefer-define-helpers` | off (migration only) | ✓ | wraps `link` / `script` tag object literals in `defineLink` / `defineScript` |
|
|
38
|
+
| `preload-font-crossorigin` | error | ✓ | font preloads missing `crossorigin` (would refetch) |
|
|
39
|
+
| `preload-missing-as` | error | | `<link rel="preload">` missing required `as` |
|
|
40
|
+
| `robots-conflict` | error | | `index, noindex` or `follow, nofollow` in robots meta |
|
|
41
|
+
| `script-src-with-content` | error | | a script with both `src` and inline content |
|
|
42
|
+
| `twitter-handle-missing-at` | warn | ✓ | `twitter:site` / `twitter:creator` missing `@` |
|
|
43
|
+
| `viewport-user-scalable` | warn | | `user-scalable=no` or `maximum-scale=1` (accessibility) |
|
|
44
|
+
|
|
45
|
+
## Coverage
|
|
46
|
+
|
|
47
|
+
These rules walk source-level calls into the unhead API: `useHead`, `useHeadSafe`, `useServerHead`, `useServerHeadSafe`, `useSeoMeta`, `useServerSeoMeta`, and the tag helpers `defineLink` / `defineScript`. Tag arrays inside `meta` / `link` / `script` / `noscript` / `style` keys are descended automatically.
|
|
48
|
+
|
|
49
|
+
Rules can only see what's expressible in the AST. Cross-tag and rendered-output checks (e.g. `canonical-og-url-mismatch`, `meta-beyond-1mb`, `charset-not-early`, `too-many-preloads`) live in the runtime `ValidatePlugin` and are surfaced by the `unhead validate-url` / `unhead validate-html` CLI commands.
|
|
50
|
+
|
|
51
|
+
## Rule constants
|
|
52
|
+
|
|
53
|
+
The plugin imports its rule IDs and known-meta sets from `unhead/validate`, the same module the runtime `ValidatePlugin` reads. This keeps lint diagnostics, runtime warnings, and CLI reports aligned by construction.
|
package/dist/index.d.mts
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import * as eslint from 'eslint';
|
|
2
|
+
import { Linter, ESLint } from 'eslint';
|
|
3
|
+
|
|
4
|
+
declare const rules: {
|
|
5
|
+
readonly 'defer-on-module-script': eslint.Rule.RuleModule;
|
|
6
|
+
readonly 'empty-meta-content': eslint.Rule.RuleModule;
|
|
7
|
+
readonly 'no-deprecated-props': eslint.Rule.RuleModule;
|
|
8
|
+
readonly 'no-html-in-title': eslint.Rule.RuleModule;
|
|
9
|
+
readonly 'no-unknown-meta': eslint.Rule.RuleModule;
|
|
10
|
+
readonly 'non-absolute-canonical': eslint.Rule.RuleModule;
|
|
11
|
+
readonly 'numeric-tag-priority': eslint.Rule.RuleModule;
|
|
12
|
+
readonly 'prefer-define-helpers': eslint.Rule.RuleModule;
|
|
13
|
+
readonly 'preload-font-crossorigin': eslint.Rule.RuleModule;
|
|
14
|
+
readonly 'preload-missing-as': eslint.Rule.RuleModule;
|
|
15
|
+
readonly 'robots-conflict': eslint.Rule.RuleModule;
|
|
16
|
+
readonly 'script-src-with-content': eslint.Rule.RuleModule;
|
|
17
|
+
readonly 'twitter-handle-missing-at': eslint.Rule.RuleModule;
|
|
18
|
+
readonly 'viewport-user-scalable': eslint.Rule.RuleModule;
|
|
19
|
+
};
|
|
20
|
+
declare const plugin: ESLint.Plugin;
|
|
21
|
+
declare const configs: Record<'recommended' | 'migration', Linter.Config>;
|
|
22
|
+
|
|
23
|
+
export { configs, plugin as default, rules };
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import * as eslint from 'eslint';
|
|
2
|
+
import { Linter, ESLint } from 'eslint';
|
|
3
|
+
|
|
4
|
+
declare const rules: {
|
|
5
|
+
readonly 'defer-on-module-script': eslint.Rule.RuleModule;
|
|
6
|
+
readonly 'empty-meta-content': eslint.Rule.RuleModule;
|
|
7
|
+
readonly 'no-deprecated-props': eslint.Rule.RuleModule;
|
|
8
|
+
readonly 'no-html-in-title': eslint.Rule.RuleModule;
|
|
9
|
+
readonly 'no-unknown-meta': eslint.Rule.RuleModule;
|
|
10
|
+
readonly 'non-absolute-canonical': eslint.Rule.RuleModule;
|
|
11
|
+
readonly 'numeric-tag-priority': eslint.Rule.RuleModule;
|
|
12
|
+
readonly 'prefer-define-helpers': eslint.Rule.RuleModule;
|
|
13
|
+
readonly 'preload-font-crossorigin': eslint.Rule.RuleModule;
|
|
14
|
+
readonly 'preload-missing-as': eslint.Rule.RuleModule;
|
|
15
|
+
readonly 'robots-conflict': eslint.Rule.RuleModule;
|
|
16
|
+
readonly 'script-src-with-content': eslint.Rule.RuleModule;
|
|
17
|
+
readonly 'twitter-handle-missing-at': eslint.Rule.RuleModule;
|
|
18
|
+
readonly 'viewport-user-scalable': eslint.Rule.RuleModule;
|
|
19
|
+
};
|
|
20
|
+
declare const plugin: ESLint.Plugin;
|
|
21
|
+
declare const configs: Record<'recommended' | 'migration', Linter.Config>;
|
|
22
|
+
|
|
23
|
+
export { configs, plugin as default, rules };
|
package/dist/index.mjs
ADDED
|
@@ -0,0 +1,540 @@
|
|
|
1
|
+
import { nonAbsoluteCanonical as nonAbsoluteCanonical$1, emptyMetaContent as emptyMetaContent$1, noDeprecatedProps as noDeprecatedProps$1, noUnknownMeta as noUnknownMeta$1, numericTagPriority as numericTagPriority$1, preferDefineHelpers as preferDefineHelpers$1, preloadMissingAs as preloadMissingAs$1, preloadFontCrossorigin as preloadFontCrossorigin$1, robotsConflict as robotsConflict$1, deferOnModuleScript as deferOnModuleScript$1, scriptSrcWithContent as scriptSrcWithContent$1, noHtmlInTitle as noHtmlInTitle$1, twitterHandleMissingAt as twitterHandleMissingAt$1, viewportUserScalable as viewportUserScalable$1 } from 'unhead/validate';
|
|
2
|
+
|
|
3
|
+
const recommendedRules = {
|
|
4
|
+
"@unhead/defer-on-module-script": "warn",
|
|
5
|
+
"@unhead/empty-meta-content": "warn",
|
|
6
|
+
"@unhead/no-deprecated-props": "error",
|
|
7
|
+
"@unhead/no-html-in-title": "warn",
|
|
8
|
+
"@unhead/no-unknown-meta": "warn",
|
|
9
|
+
"@unhead/non-absolute-canonical": "warn",
|
|
10
|
+
"@unhead/numeric-tag-priority": "warn",
|
|
11
|
+
"@unhead/preload-font-crossorigin": "error",
|
|
12
|
+
"@unhead/preload-missing-as": "error",
|
|
13
|
+
"@unhead/robots-conflict": "error",
|
|
14
|
+
"@unhead/script-src-with-content": "error",
|
|
15
|
+
"@unhead/twitter-handle-missing-at": "warn",
|
|
16
|
+
"@unhead/viewport-user-scalable": "warn"
|
|
17
|
+
};
|
|
18
|
+
const recommended = {
|
|
19
|
+
plugins: {},
|
|
20
|
+
rules: recommendedRules
|
|
21
|
+
};
|
|
22
|
+
const migration = {
|
|
23
|
+
plugins: {},
|
|
24
|
+
rules: {
|
|
25
|
+
...recommendedRules,
|
|
26
|
+
"@unhead/prefer-define-helpers": "warn"
|
|
27
|
+
}
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
const HEAD_INPUT_CALLEES = /* @__PURE__ */ new Set([
|
|
31
|
+
"useHead",
|
|
32
|
+
"useHeadSafe",
|
|
33
|
+
"useServerHead",
|
|
34
|
+
"useServerHeadSafe",
|
|
35
|
+
"useSeoMeta",
|
|
36
|
+
"useServerSeoMeta"
|
|
37
|
+
]);
|
|
38
|
+
const TAG_HELPER_CALLEES = /* @__PURE__ */ new Set([
|
|
39
|
+
"defineLink",
|
|
40
|
+
"defineScript"
|
|
41
|
+
]);
|
|
42
|
+
const HEAD_INPUT_TAG_KEYS = /* @__PURE__ */ new Set([
|
|
43
|
+
"meta",
|
|
44
|
+
"link",
|
|
45
|
+
"script",
|
|
46
|
+
"noscript",
|
|
47
|
+
"style"
|
|
48
|
+
]);
|
|
49
|
+
function getCalleeName(node) {
|
|
50
|
+
const callee = node.callee;
|
|
51
|
+
if (callee.type === "Identifier")
|
|
52
|
+
return callee.name;
|
|
53
|
+
if (callee.type === "MemberExpression" && callee.property.type === "Identifier")
|
|
54
|
+
return callee.property.name;
|
|
55
|
+
return void 0;
|
|
56
|
+
}
|
|
57
|
+
function unwrapTS(node) {
|
|
58
|
+
if (!node)
|
|
59
|
+
return node;
|
|
60
|
+
const t = node.type;
|
|
61
|
+
if (t === "TSAsExpression" || t === "TSTypeAssertion" || t === "TSNonNullExpression" || t === "TSSatisfiesExpression" || t === "TSInstantiationExpression")
|
|
62
|
+
return unwrapTS(node.expression);
|
|
63
|
+
return node;
|
|
64
|
+
}
|
|
65
|
+
function getStringValue(node) {
|
|
66
|
+
const inner = unwrapTS(node);
|
|
67
|
+
if (!inner)
|
|
68
|
+
return void 0;
|
|
69
|
+
if (inner.type === "Literal" && typeof inner.value === "string")
|
|
70
|
+
return inner.value;
|
|
71
|
+
if (inner.type === "TemplateLiteral" && inner.expressions.length === 0 && inner.quasis.length === 1)
|
|
72
|
+
return inner.quasis[0].value.cooked ?? void 0;
|
|
73
|
+
return void 0;
|
|
74
|
+
}
|
|
75
|
+
function findProperty(obj, key) {
|
|
76
|
+
for (let i = obj.properties.length - 1; i >= 0; i--) {
|
|
77
|
+
const prop = obj.properties[i];
|
|
78
|
+
if (prop.type !== "Property" || prop.computed)
|
|
79
|
+
continue;
|
|
80
|
+
const k = prop.key;
|
|
81
|
+
if (k.type === "Identifier" && k.name === key)
|
|
82
|
+
return prop;
|
|
83
|
+
if (k.type === "Literal" && k.value === key)
|
|
84
|
+
return prop;
|
|
85
|
+
}
|
|
86
|
+
return void 0;
|
|
87
|
+
}
|
|
88
|
+
function createTagVisitor(visitor) {
|
|
89
|
+
return (ctx) => {
|
|
90
|
+
function visitTagArray(arr, tagType) {
|
|
91
|
+
if (!arr || !visitor.onTag)
|
|
92
|
+
return;
|
|
93
|
+
for (const el of arr.elements) {
|
|
94
|
+
const inner = unwrapTS(el);
|
|
95
|
+
if (inner && inner.type === "ObjectExpression")
|
|
96
|
+
visitor.onTag(inner, tagType, ctx, { inArray: true });
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
return {
|
|
100
|
+
CallExpression(node) {
|
|
101
|
+
const name = getCalleeName(node);
|
|
102
|
+
if (!name)
|
|
103
|
+
return;
|
|
104
|
+
if (TAG_HELPER_CALLEES.has(name)) {
|
|
105
|
+
const arg2 = unwrapTS(node.arguments[0]);
|
|
106
|
+
if (arg2?.type === "ObjectExpression") {
|
|
107
|
+
const tagType = name.slice("define".length).toLowerCase();
|
|
108
|
+
visitor.onTag?.(arg2, tagType, ctx, { inArray: false });
|
|
109
|
+
}
|
|
110
|
+
return;
|
|
111
|
+
}
|
|
112
|
+
if (!HEAD_INPUT_CALLEES.has(name))
|
|
113
|
+
return;
|
|
114
|
+
const arg = unwrapTS(node.arguments[0]);
|
|
115
|
+
if (arg?.type !== "ObjectExpression")
|
|
116
|
+
return;
|
|
117
|
+
visitor.onHeadInput?.(arg, name, ctx);
|
|
118
|
+
if (name === "useSeoMeta" || name === "useServerSeoMeta")
|
|
119
|
+
return;
|
|
120
|
+
for (const prop of arg.properties) {
|
|
121
|
+
if (prop.type !== "Property" || prop.computed)
|
|
122
|
+
continue;
|
|
123
|
+
const key = prop.key.type === "Identifier" ? prop.key.name : prop.key.type === "Literal" && typeof prop.key.value === "string" ? prop.key.value : void 0;
|
|
124
|
+
if (!key || !HEAD_INPUT_TAG_KEYS.has(key))
|
|
125
|
+
continue;
|
|
126
|
+
const value = unwrapTS(prop.value);
|
|
127
|
+
if (value?.type === "ArrayExpression")
|
|
128
|
+
visitTagArray(value, key);
|
|
129
|
+
else if (value?.type === "ObjectExpression")
|
|
130
|
+
visitor.onTag?.(value, key, ctx, { inArray: false });
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
};
|
|
134
|
+
};
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function locFor(obj, at) {
|
|
138
|
+
if (!at || at.kind === "tag")
|
|
139
|
+
return obj;
|
|
140
|
+
const prop = findProperty(obj, at.key);
|
|
141
|
+
if (!prop)
|
|
142
|
+
return obj;
|
|
143
|
+
if (at.kind === "prop")
|
|
144
|
+
return prop;
|
|
145
|
+
if (at.kind === "prop-key")
|
|
146
|
+
return prop.key;
|
|
147
|
+
return prop.value;
|
|
148
|
+
}
|
|
149
|
+
function buildFixer(obj, fix, sourceCode) {
|
|
150
|
+
switch (fix.type) {
|
|
151
|
+
case "rename-prop": {
|
|
152
|
+
const prop = findProperty(obj, fix.key);
|
|
153
|
+
if (!prop)
|
|
154
|
+
return void 0;
|
|
155
|
+
return (fixer) => fixer.replaceText(prop.key, fix.newKey);
|
|
156
|
+
}
|
|
157
|
+
case "replace-prop-value": {
|
|
158
|
+
const prop = findProperty(obj, fix.key);
|
|
159
|
+
if (!prop)
|
|
160
|
+
return void 0;
|
|
161
|
+
return (fixer) => fixer.replaceText(prop.value, fix.newSource);
|
|
162
|
+
}
|
|
163
|
+
case "replace-prop": {
|
|
164
|
+
const prop = findProperty(obj, fix.key);
|
|
165
|
+
if (!prop)
|
|
166
|
+
return void 0;
|
|
167
|
+
return (fixer) => fixer.replaceText(prop, fix.newSource);
|
|
168
|
+
}
|
|
169
|
+
case "insert-after-prop": {
|
|
170
|
+
const prop = findProperty(obj, fix.afterKey);
|
|
171
|
+
if (!prop)
|
|
172
|
+
return void 0;
|
|
173
|
+
return (fixer) => fixer.insertTextAfter(prop, fix.insert);
|
|
174
|
+
}
|
|
175
|
+
case "remove-prop": {
|
|
176
|
+
const prop = findProperty(obj, fix.key);
|
|
177
|
+
if (!prop)
|
|
178
|
+
return void 0;
|
|
179
|
+
return (fixer) => {
|
|
180
|
+
const after = sourceCode.getTokenAfter(prop);
|
|
181
|
+
if (after && after.value === ",")
|
|
182
|
+
return fixer.removeRange([prop.range[0], after.range[1]]);
|
|
183
|
+
const before = sourceCode.getTokenBefore(prop);
|
|
184
|
+
if (before && before.value === ",")
|
|
185
|
+
return fixer.removeRange([before.range[0], prop.range[1]]);
|
|
186
|
+
return fixer.remove(prop);
|
|
187
|
+
};
|
|
188
|
+
}
|
|
189
|
+
case "wrap-tag": {
|
|
190
|
+
return (fixer) => fixer.replaceText(obj, `${fix.wrapWith}(${sourceCode.getText(obj)})`);
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
function reportDiagnostic(ctx, obj, diag) {
|
|
195
|
+
const node = locFor(obj, diag.at);
|
|
196
|
+
const fixer = diag.fix ? buildFixer(obj, diag.fix, ctx.sourceCode) : void 0;
|
|
197
|
+
ctx.report({
|
|
198
|
+
node,
|
|
199
|
+
message: diag.message,
|
|
200
|
+
fix: fixer,
|
|
201
|
+
suggest: diag.suggestions?.map((s) => ({
|
|
202
|
+
desc: s.message,
|
|
203
|
+
fix: buildFixer(obj, s.fix, ctx.sourceCode) ?? (() => null)
|
|
204
|
+
}))
|
|
205
|
+
});
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
function materializeTag(node, tagType, inArray) {
|
|
209
|
+
const props = {};
|
|
210
|
+
const keys = /* @__PURE__ */ new Set();
|
|
211
|
+
const propLocs = {};
|
|
212
|
+
for (const p of node.properties) {
|
|
213
|
+
if (p.type !== "Property" || p.computed)
|
|
214
|
+
continue;
|
|
215
|
+
const k = p.key;
|
|
216
|
+
const name = k.type === "Identifier" ? k.name : k.type === "Literal" && typeof k.value === "string" ? k.value : void 0;
|
|
217
|
+
if (!name)
|
|
218
|
+
continue;
|
|
219
|
+
keys.add(name);
|
|
220
|
+
propLocs[name] = p;
|
|
221
|
+
const value = unwrapTS(p.value);
|
|
222
|
+
if (!value)
|
|
223
|
+
continue;
|
|
224
|
+
if (value.type === "Literal") {
|
|
225
|
+
const v = value.value;
|
|
226
|
+
if (typeof v === "string" || typeof v === "number" || typeof v === "boolean")
|
|
227
|
+
props[name] = v;
|
|
228
|
+
continue;
|
|
229
|
+
}
|
|
230
|
+
const str = getStringValue(value);
|
|
231
|
+
if (str !== void 0)
|
|
232
|
+
props[name] = str;
|
|
233
|
+
}
|
|
234
|
+
return { tagType, props, keys, loc: node, propLocs, inArray };
|
|
235
|
+
}
|
|
236
|
+
function materializeHeadInput(node, callee) {
|
|
237
|
+
const props = {};
|
|
238
|
+
const keys = /* @__PURE__ */ new Set();
|
|
239
|
+
const propLocs = {};
|
|
240
|
+
for (const p of node.properties) {
|
|
241
|
+
if (p.type !== "Property" || p.computed)
|
|
242
|
+
continue;
|
|
243
|
+
const k = p.key;
|
|
244
|
+
const name = k.type === "Identifier" ? k.name : k.type === "Literal" && typeof k.value === "string" ? k.value : void 0;
|
|
245
|
+
if (!name)
|
|
246
|
+
continue;
|
|
247
|
+
keys.add(name);
|
|
248
|
+
propLocs[name] = p;
|
|
249
|
+
if (name === "title" || name === "titleTemplate") {
|
|
250
|
+
const str = getStringValue(p.value);
|
|
251
|
+
if (str !== void 0)
|
|
252
|
+
props[name] = str;
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
return { callee, props, keys, loc: node, propLocs };
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
const HELPER_NAMES = /* @__PURE__ */ new Set(["defineLink", "defineScript"]);
|
|
259
|
+
const HELPER_SOURCES = /* @__PURE__ */ new Set([
|
|
260
|
+
"unhead",
|
|
261
|
+
"@unhead/vue",
|
|
262
|
+
"@unhead/react",
|
|
263
|
+
"@unhead/svelte",
|
|
264
|
+
"@unhead/solid-js",
|
|
265
|
+
"@unhead/angular"
|
|
266
|
+
]);
|
|
267
|
+
function isHelperSource(source) {
|
|
268
|
+
if (HELPER_SOURCES.has(source))
|
|
269
|
+
return true;
|
|
270
|
+
return source.startsWith("@unhead/");
|
|
271
|
+
}
|
|
272
|
+
function collectImportedHelpers(program) {
|
|
273
|
+
const out = /* @__PURE__ */ new Map();
|
|
274
|
+
for (const node of program.body) {
|
|
275
|
+
if (node.type !== "ImportDeclaration")
|
|
276
|
+
continue;
|
|
277
|
+
const source = typeof node.source?.value === "string" ? node.source.value : "";
|
|
278
|
+
if (!isHelperSource(source))
|
|
279
|
+
continue;
|
|
280
|
+
for (const spec of node.specifiers) {
|
|
281
|
+
if (spec.type === "ImportSpecifier" && spec.imported.type === "Identifier" && HELPER_NAMES.has(spec.imported.name))
|
|
282
|
+
out.set(spec.imported.name, spec.local.name);
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
return out;
|
|
286
|
+
}
|
|
287
|
+
function createTagPredicateRule(predicate, opts = {}) {
|
|
288
|
+
return (ctx) => {
|
|
289
|
+
let helpers;
|
|
290
|
+
function ensureHelpers() {
|
|
291
|
+
if (!helpers)
|
|
292
|
+
helpers = collectImportedHelpers(ctx.sourceCode.ast);
|
|
293
|
+
return helpers;
|
|
294
|
+
}
|
|
295
|
+
return createTagVisitor({
|
|
296
|
+
onTag(tag, tagType, _ctx, info) {
|
|
297
|
+
const input = materializeTag(tag, tagType, info.inArray);
|
|
298
|
+
const pctx = opts.needsHelpers ? { importedHelpers: ensureHelpers() } : void 0;
|
|
299
|
+
for (const diag of predicate(input, pctx))
|
|
300
|
+
reportDiagnostic(ctx, tag, diag);
|
|
301
|
+
}
|
|
302
|
+
})(ctx);
|
|
303
|
+
};
|
|
304
|
+
}
|
|
305
|
+
function createHeadInputPredicateRule(predicate) {
|
|
306
|
+
return (ctx) => ({
|
|
307
|
+
CallExpression(node) {
|
|
308
|
+
const name = getCalleeName(node);
|
|
309
|
+
if (!name || !HEAD_INPUT_CALLEES.has(name))
|
|
310
|
+
return;
|
|
311
|
+
const arg = unwrapTS(node.arguments[0]);
|
|
312
|
+
if (!arg || arg.type !== "ObjectExpression")
|
|
313
|
+
return;
|
|
314
|
+
const input = materializeHeadInput(arg, name);
|
|
315
|
+
for (const diag of predicate(input))
|
|
316
|
+
reportDiagnostic(ctx, arg, diag);
|
|
317
|
+
}
|
|
318
|
+
});
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
const nonAbsoluteCanonical = {
|
|
322
|
+
meta: {
|
|
323
|
+
type: "suggestion",
|
|
324
|
+
docs: {
|
|
325
|
+
description: "Canonical URLs must be absolute.",
|
|
326
|
+
recommended: true,
|
|
327
|
+
url: "https://developers.google.com/search/docs/crawling-indexing/canonicalization"
|
|
328
|
+
},
|
|
329
|
+
schema: []
|
|
330
|
+
},
|
|
331
|
+
create: createTagPredicateRule(nonAbsoluteCanonical$1)
|
|
332
|
+
};
|
|
333
|
+
|
|
334
|
+
const emptyMetaContent = {
|
|
335
|
+
meta: {
|
|
336
|
+
type: "suggestion",
|
|
337
|
+
docs: {
|
|
338
|
+
description: "Disallow meta tags with empty `content`.",
|
|
339
|
+
recommended: true,
|
|
340
|
+
url: "https://unhead.unjs.io/docs/typescript/head/api/composables/use-seo-meta"
|
|
341
|
+
},
|
|
342
|
+
schema: []
|
|
343
|
+
},
|
|
344
|
+
create: createTagPredicateRule(emptyMetaContent$1)
|
|
345
|
+
};
|
|
346
|
+
|
|
347
|
+
const noDeprecatedProps = {
|
|
348
|
+
meta: {
|
|
349
|
+
type: "problem",
|
|
350
|
+
docs: {
|
|
351
|
+
description: "Disallow deprecated v2 unhead tag props (children, hid/vmid, body).",
|
|
352
|
+
recommended: true,
|
|
353
|
+
url: "https://unhead.unjs.io/docs/migration/v2-to-v3"
|
|
354
|
+
},
|
|
355
|
+
fixable: "code",
|
|
356
|
+
schema: []
|
|
357
|
+
},
|
|
358
|
+
create: createTagPredicateRule(noDeprecatedProps$1)
|
|
359
|
+
};
|
|
360
|
+
|
|
361
|
+
const noUnknownMeta = {
|
|
362
|
+
meta: {
|
|
363
|
+
type: "suggestion",
|
|
364
|
+
docs: {
|
|
365
|
+
description: "Detect typos in meta `name` and `property` values.",
|
|
366
|
+
recommended: true,
|
|
367
|
+
url: "https://unhead.unjs.io/docs/typescript/head/api/composables/use-seo-meta"
|
|
368
|
+
},
|
|
369
|
+
fixable: "code",
|
|
370
|
+
schema: []
|
|
371
|
+
},
|
|
372
|
+
create: createTagPredicateRule(noUnknownMeta$1)
|
|
373
|
+
};
|
|
374
|
+
|
|
375
|
+
const numericTagPriority = {
|
|
376
|
+
meta: {
|
|
377
|
+
type: "suggestion",
|
|
378
|
+
docs: {
|
|
379
|
+
description: "Prefer string aliases for `tagPriority` over raw numbers.",
|
|
380
|
+
recommended: true,
|
|
381
|
+
url: "https://unhead.unjs.io/docs/typescript/head/api/options/tag-priority"
|
|
382
|
+
},
|
|
383
|
+
hasSuggestions: true,
|
|
384
|
+
schema: []
|
|
385
|
+
},
|
|
386
|
+
create: createTagPredicateRule(numericTagPriority$1)
|
|
387
|
+
};
|
|
388
|
+
|
|
389
|
+
const preferDefineHelpers = {
|
|
390
|
+
meta: {
|
|
391
|
+
type: "suggestion",
|
|
392
|
+
docs: {
|
|
393
|
+
description: "Wrap tag object literals in their `defineX` helper for type narrowing.",
|
|
394
|
+
recommended: false,
|
|
395
|
+
url: "https://unhead.unjs.io/docs/typescript/head/api/utilities"
|
|
396
|
+
},
|
|
397
|
+
fixable: "code",
|
|
398
|
+
hasSuggestions: true,
|
|
399
|
+
schema: []
|
|
400
|
+
},
|
|
401
|
+
create: createTagPredicateRule(preferDefineHelpers$1, { needsHelpers: true })
|
|
402
|
+
};
|
|
403
|
+
|
|
404
|
+
const preloadMissingAs = {
|
|
405
|
+
meta: {
|
|
406
|
+
type: "problem",
|
|
407
|
+
docs: {
|
|
408
|
+
description: '`<link rel="preload">` requires an `as` attribute.',
|
|
409
|
+
recommended: true,
|
|
410
|
+
url: "https://developer.mozilla.org/en-US/docs/Web/HTML/Attributes/rel/preload"
|
|
411
|
+
},
|
|
412
|
+
schema: []
|
|
413
|
+
},
|
|
414
|
+
create: createTagPredicateRule(preloadMissingAs$1)
|
|
415
|
+
};
|
|
416
|
+
const preloadFontCrossorigin = {
|
|
417
|
+
meta: {
|
|
418
|
+
type: "problem",
|
|
419
|
+
docs: {
|
|
420
|
+
description: "Font preloads require the `crossorigin` attribute.",
|
|
421
|
+
recommended: true,
|
|
422
|
+
url: "https://developer.mozilla.org/en-US/docs/Web/HTML/Attributes/rel/preload#cors-enabled_fetches"
|
|
423
|
+
},
|
|
424
|
+
fixable: "code",
|
|
425
|
+
schema: []
|
|
426
|
+
},
|
|
427
|
+
create: createTagPredicateRule(preloadFontCrossorigin$1)
|
|
428
|
+
};
|
|
429
|
+
|
|
430
|
+
const robotsConflict = {
|
|
431
|
+
meta: {
|
|
432
|
+
type: "problem",
|
|
433
|
+
docs: {
|
|
434
|
+
description: "Disallow conflicting directives in `robots` meta content.",
|
|
435
|
+
recommended: true,
|
|
436
|
+
url: "https://developers.google.com/search/docs/crawling-indexing/robots-meta-tag"
|
|
437
|
+
},
|
|
438
|
+
schema: []
|
|
439
|
+
},
|
|
440
|
+
create: createTagPredicateRule(robotsConflict$1)
|
|
441
|
+
};
|
|
442
|
+
|
|
443
|
+
const deferOnModuleScript = {
|
|
444
|
+
meta: {
|
|
445
|
+
type: "suggestion",
|
|
446
|
+
docs: {
|
|
447
|
+
description: '`defer` is redundant on `type="module"` scripts.',
|
|
448
|
+
recommended: true,
|
|
449
|
+
url: "https://developer.mozilla.org/en-US/docs/Web/HTML/Element/script#attr-defer"
|
|
450
|
+
},
|
|
451
|
+
fixable: "code",
|
|
452
|
+
schema: []
|
|
453
|
+
},
|
|
454
|
+
create: createTagPredicateRule(deferOnModuleScript$1)
|
|
455
|
+
};
|
|
456
|
+
const scriptSrcWithContent = {
|
|
457
|
+
meta: {
|
|
458
|
+
type: "problem",
|
|
459
|
+
docs: {
|
|
460
|
+
description: "A script with `src` cannot also have inline content.",
|
|
461
|
+
recommended: true,
|
|
462
|
+
url: "https://developer.mozilla.org/en-US/docs/Web/HTML/Element/script"
|
|
463
|
+
},
|
|
464
|
+
schema: []
|
|
465
|
+
},
|
|
466
|
+
create: createTagPredicateRule(scriptSrcWithContent$1)
|
|
467
|
+
};
|
|
468
|
+
|
|
469
|
+
const noHtmlInTitle = {
|
|
470
|
+
meta: {
|
|
471
|
+
type: "suggestion",
|
|
472
|
+
docs: {
|
|
473
|
+
description: "HTML characters in `<title>` are escaped, not rendered.",
|
|
474
|
+
recommended: true,
|
|
475
|
+
url: "https://html.spec.whatwg.org/multipage/semantics.html#the-title-element"
|
|
476
|
+
},
|
|
477
|
+
schema: []
|
|
478
|
+
},
|
|
479
|
+
create: createHeadInputPredicateRule(noHtmlInTitle$1)
|
|
480
|
+
};
|
|
481
|
+
|
|
482
|
+
const twitterHandleMissingAt = {
|
|
483
|
+
meta: {
|
|
484
|
+
type: "suggestion",
|
|
485
|
+
docs: {
|
|
486
|
+
description: "Twitter handle meta values must start with `@`.",
|
|
487
|
+
recommended: true,
|
|
488
|
+
url: "https://developer.x.com/en/docs/x-for-websites/cards/overview/markup"
|
|
489
|
+
},
|
|
490
|
+
fixable: "code",
|
|
491
|
+
schema: []
|
|
492
|
+
},
|
|
493
|
+
create: createTagPredicateRule(twitterHandleMissingAt$1)
|
|
494
|
+
};
|
|
495
|
+
|
|
496
|
+
const viewportUserScalable = {
|
|
497
|
+
meta: {
|
|
498
|
+
type: "suggestion",
|
|
499
|
+
docs: {
|
|
500
|
+
description: "Disallow viewport meta values that block user zoom (a11y).",
|
|
501
|
+
recommended: true,
|
|
502
|
+
url: "https://www.w3.org/WAI/WCAG22/Understanding/reflow.html"
|
|
503
|
+
},
|
|
504
|
+
schema: []
|
|
505
|
+
},
|
|
506
|
+
create: createTagPredicateRule(viewportUserScalable$1)
|
|
507
|
+
};
|
|
508
|
+
|
|
509
|
+
const rules = {
|
|
510
|
+
"defer-on-module-script": deferOnModuleScript,
|
|
511
|
+
"empty-meta-content": emptyMetaContent,
|
|
512
|
+
"no-deprecated-props": noDeprecatedProps,
|
|
513
|
+
"no-html-in-title": noHtmlInTitle,
|
|
514
|
+
"no-unknown-meta": noUnknownMeta,
|
|
515
|
+
"non-absolute-canonical": nonAbsoluteCanonical,
|
|
516
|
+
"numeric-tag-priority": numericTagPriority,
|
|
517
|
+
"prefer-define-helpers": preferDefineHelpers,
|
|
518
|
+
"preload-font-crossorigin": preloadFontCrossorigin,
|
|
519
|
+
"preload-missing-as": preloadMissingAs,
|
|
520
|
+
"robots-conflict": robotsConflict,
|
|
521
|
+
"script-src-with-content": scriptSrcWithContent,
|
|
522
|
+
"twitter-handle-missing-at": twitterHandleMissingAt,
|
|
523
|
+
"viewport-user-scalable": viewportUserScalable
|
|
524
|
+
};
|
|
525
|
+
const plugin = {
|
|
526
|
+
meta: {
|
|
527
|
+
name: "@unhead/eslint-plugin",
|
|
528
|
+
version: "3.0.5"
|
|
529
|
+
},
|
|
530
|
+
rules
|
|
531
|
+
};
|
|
532
|
+
function withPlugin(config) {
|
|
533
|
+
return { ...config, plugins: { "@unhead": plugin } };
|
|
534
|
+
}
|
|
535
|
+
const configs = {
|
|
536
|
+
recommended: withPlugin(recommended),
|
|
537
|
+
migration: withPlugin(migration)
|
|
538
|
+
};
|
|
539
|
+
|
|
540
|
+
export { configs, plugin as default, rules };
|
package/package.json
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@unhead/eslint-plugin",
|
|
3
|
+
"type": "module",
|
|
4
|
+
"version": "3.0.5",
|
|
5
|
+
"description": "ESLint rules for catching unhead misuse, type-narrowing issues, and v2-to-v3 migration problems.",
|
|
6
|
+
"author": "Harlan Wilton <harlan@harlanzw.com>",
|
|
7
|
+
"license": "MIT",
|
|
8
|
+
"funding": "https://github.com/sponsors/harlan-zw",
|
|
9
|
+
"homepage": "https://unhead.unjs.io",
|
|
10
|
+
"repository": {
|
|
11
|
+
"type": "git",
|
|
12
|
+
"url": "git+https://github.com/unjs/unhead.git",
|
|
13
|
+
"directory": "packages/eslint-plugin"
|
|
14
|
+
},
|
|
15
|
+
"publishConfig": {
|
|
16
|
+
"access": "public",
|
|
17
|
+
"tag": "next"
|
|
18
|
+
},
|
|
19
|
+
"bugs": {
|
|
20
|
+
"url": "https://github.com/unjs/unhead/issues"
|
|
21
|
+
},
|
|
22
|
+
"keywords": [
|
|
23
|
+
"eslint",
|
|
24
|
+
"eslint-plugin",
|
|
25
|
+
"unhead",
|
|
26
|
+
"head",
|
|
27
|
+
"seo"
|
|
28
|
+
],
|
|
29
|
+
"sideEffects": false,
|
|
30
|
+
"exports": {
|
|
31
|
+
".": {
|
|
32
|
+
"types": "./dist/index.d.ts",
|
|
33
|
+
"default": "./dist/index.mjs"
|
|
34
|
+
}
|
|
35
|
+
},
|
|
36
|
+
"main": "dist/index.mjs",
|
|
37
|
+
"module": "dist/index.mjs",
|
|
38
|
+
"types": "dist/index.d.ts",
|
|
39
|
+
"files": [
|
|
40
|
+
"*.d.ts",
|
|
41
|
+
"dist"
|
|
42
|
+
],
|
|
43
|
+
"peerDependencies": {
|
|
44
|
+
"eslint": ">=9.0.0",
|
|
45
|
+
"unhead": "^3.0.5"
|
|
46
|
+
},
|
|
47
|
+
"dependencies": {
|
|
48
|
+
"unhead": "3.0.5"
|
|
49
|
+
},
|
|
50
|
+
"devDependencies": {
|
|
51
|
+
"@types/estree": "^1.0.8",
|
|
52
|
+
"eslint": "^10.2.1",
|
|
53
|
+
"vitest": "^4.1.5"
|
|
54
|
+
},
|
|
55
|
+
"scripts": {
|
|
56
|
+
"build": "unbuild",
|
|
57
|
+
"stub": "unbuild --stub",
|
|
58
|
+
"test": "vitest run"
|
|
59
|
+
}
|
|
60
|
+
}
|