Skip to content

Commit 7cebf8f

Browse files
Merge pull request #2474 from NullVoxPopuli/nvp/template-lint-extract-rule-template-no-invalid-meta
Extract rule: template-no-invalid-meta
2 parents 525cf6b + 8a08b4f commit 7cebf8f

4 files changed

Lines changed: 484 additions & 0 deletions

File tree

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -210,6 +210,7 @@ rules in templates can be disabled with eslint directives with mustache or html
210210
| [template-no-inline-styles](docs/rules/template-no-inline-styles.md) | disallow inline styles | | | |
211211
| [template-no-input-placeholder](docs/rules/template-no-input-placeholder.md) | disallow placeholder attribute on input elements | | | |
212212
| [template-no-input-tagname](docs/rules/template-no-input-tagname.md) | disallow tagName attribute on {{input}} helper | | | |
213+
| [template-no-invalid-meta](docs/rules/template-no-invalid-meta.md) | disallow invalid meta tags | | | |
213214
| [template-no-log](docs/rules/template-no-log.md) | disallow {{log}} in templates | | | |
214215
| [template-no-model-argument-in-route-templates](docs/rules/template-no-model-argument-in-route-templates.md) | disallow @model argument in route templates | | 🔧 | |
215216
| [template-no-multiple-empty-lines](docs/rules/template-no-multiple-empty-lines.md) | disallow multiple consecutive empty lines in templates | | | |
Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,117 @@
1+
# ember/template-no-invalid-meta
2+
3+
<!-- end auto-generated rule header -->
4+
5+
Disallow invalid meta tags.
6+
7+
Meta tags must be well-formed and follow accessibility/usability best practices.
8+
9+
## Rule Details
10+
11+
This rule checks `<meta>` elements for the following issues:
12+
13+
1. **Invalid charset**`charset` must be `utf-8` (case-insensitive).
14+
2. **Missing `content`** — If `name`, `property`, `itemprop`, or `http-equiv` is present, a `content` attribute is required.
15+
3. **Missing identifier** — If `content` is present, one of `name`, `property`, `itemprop`, `http-equiv`, or `charset` must also be present.
16+
4. **`http-equiv="refresh"` redirect delay** — A meta refresh that redirects (contains `;`) must have a delay of `0`.
17+
5. **`http-equiv="refresh"` plain delay** — A meta refresh without a redirect must have a delay greater than 72000 seconds.
18+
6. **Viewport `user-scalable=no`** — Disabling user scaling harms accessibility.
19+
7. **Viewport `maximum-scale`** — Setting a maximum scale restricts zooming.
20+
21+
## Redirects & Refresh
22+
23+
Sometimes a page automatically redirects to a different page. When this happens after a timed delay, it is an unexpected change of context that may interrupt the user. Redirects without timed delays are okay, but please consider a server-side method for redirecting instead (method will vary based on your server type).
24+
25+
## Orientation Lock
26+
27+
When content is presented with a restriction to a specific orientation, users must orient their devices to view the content in the orientation that the author imposed. Some users have their devices mounted in a fixed orientation (e.g. on the arm of a power wheelchair), and if the content cannot be viewed in that orientation it creates problems for the user.
28+
29+
## Examples
30+
31+
### Incorrect
32+
33+
```gjs
34+
<template>
35+
<meta charset="iso-8859-1" />
36+
</template>
37+
```
38+
39+
```gjs
40+
<template>
41+
<meta name="description" />
42+
</template>
43+
```
44+
45+
Missing `content` when `name` is present.
46+
47+
```gjs
48+
<template>
49+
<meta content="some value" />
50+
</template>
51+
```
52+
53+
Missing identifier (`name`, `property`, `itemprop`, or `http-equiv`) when `content` is present.
54+
55+
```gjs
56+
<template>
57+
<meta http-equiv="refresh" content="5;url=https://example.com" />
58+
</template>
59+
```
60+
61+
Redirect delay must be 0.
62+
63+
```gjs
64+
<template>
65+
<meta http-equiv="refresh" content="30" />
66+
</template>
67+
```
68+
69+
Plain refresh delay must be greater than 72000.
70+
71+
```gjs
72+
<template>
73+
<meta name="viewport" content="width=device-width, user-scalable=no" />
74+
</template>
75+
```
76+
77+
```gjs
78+
<template>
79+
<meta name="viewport" content="width=device-width, maximum-scale=1" />
80+
</template>
81+
```
82+
83+
### Correct
84+
85+
```gjs
86+
<template>
87+
<meta charset="utf-8" />
88+
</template>
89+
```
90+
91+
```gjs
92+
<template>
93+
<meta name="viewport" content="width=device-width, initial-scale=1" />
94+
</template>
95+
```
96+
97+
```gjs
98+
<template>
99+
<meta name="description" content="A description of the page" />
100+
</template>
101+
```
102+
103+
```gjs
104+
<template>
105+
<meta http-equiv="refresh" content="0;url=https://example.com" />
106+
</template>
107+
```
108+
109+
## Migration
110+
111+
- To fix, reduce the timed delay to zero, or use the appropriate server-side redirect method for your server type.
112+
- To fix orientation issues, remove references to `maximum-scale=1.0` and change `user-scalable=no` to `user-scalable=yes`.
113+
114+
## References
115+
116+
- [MDN - Meta charset](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/meta#attr-charset)
117+
- [WCAG - Meta Refresh](https://www.w3.org/TR/WCAG21/Understanding/timing-adjustable.html)
Lines changed: 134 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,134 @@
1+
/** @type {import('eslint').Rule.RuleModule} */
2+
module.exports = {
3+
meta: {
4+
type: 'problem',
5+
docs: {
6+
description: 'disallow invalid meta tags',
7+
category: 'Best Practices',
8+
url: 'https://github.com/ember-cli/eslint-plugin-ember/tree/master/docs/rules/template-no-invalid-meta.md',
9+
templateMode: 'both',
10+
},
11+
fixable: null,
12+
schema: [],
13+
messages: {
14+
invalidCharset: 'Meta charset should be "utf-8". Found: "{{charset}}".',
15+
metaRefreshRedirect: 'A meta redirect should not have a delay value greater than zero.',
16+
metaRefreshDelay: 'A meta refresh should have a delay greater than 72000 seconds.',
17+
viewportUserScalable: 'A meta viewport should not restrict user-scalable.',
18+
viewportMaximumScale: 'A meta viewport should not set a maximum scale on content.',
19+
metaMissingContent:
20+
'A meta content attribute must be defined if the name, property, itemprop, or http-equiv attribute is defined.',
21+
metaMissingIdentifier:
22+
'A meta content attribute cannot be defined if the name, property, itemprop, nor the http-equiv attributes are defined.',
23+
},
24+
originallyFrom: {
25+
name: 'ember-template-lint',
26+
rule: 'lib/rules/no-invalid-meta.js',
27+
docs: 'docs/rule/no-invalid-meta.md',
28+
tests: 'test/unit/rules/no-invalid-meta-test.js',
29+
},
30+
},
31+
32+
create(context) {
33+
return {
34+
// eslint-disable-next-line complexity
35+
GlimmerElementNode(node) {
36+
if (node.tag !== 'meta') {
37+
return;
38+
}
39+
40+
function findAttr(name) {
41+
return node.attributes.find((a) => a.type === 'GlimmerAttrNode' && a.name === name);
42+
}
43+
44+
function getAttrText(name) {
45+
const attr = findAttr(name);
46+
if (attr && attr.value && attr.value.type === 'GlimmerTextNode') {
47+
return attr.value.chars;
48+
}
49+
return undefined;
50+
}
51+
52+
const hasCharset = Boolean(findAttr('charset'));
53+
const hasName = Boolean(findAttr('name'));
54+
const hasHttpEquiv = Boolean(findAttr('http-equiv'));
55+
const hasProperty = Boolean(findAttr('property'));
56+
const hasItemprop = Boolean(findAttr('itemprop'));
57+
const hasContent = Boolean(findAttr('content'));
58+
const hasIdentifier = hasName || hasHttpEquiv || hasProperty || hasItemprop;
59+
60+
// Check for invalid charset value
61+
const charsetValue = getAttrText('charset');
62+
if (hasCharset && charsetValue) {
63+
const normalizedCharset = charsetValue.toLowerCase().replaceAll('-', '');
64+
if (normalizedCharset !== 'utf8') {
65+
context.report({
66+
node,
67+
messageId: 'invalidCharset',
68+
data: { charset: charsetValue },
69+
});
70+
}
71+
}
72+
73+
// Check: identifier present but no content
74+
if (hasIdentifier && !hasContent && !hasCharset) {
75+
context.report({
76+
node,
77+
messageId: 'metaMissingContent',
78+
});
79+
}
80+
81+
// Check: content present but no identifier or charset
82+
if (hasContent && !hasIdentifier && !hasCharset) {
83+
context.report({
84+
node,
85+
messageId: 'metaMissingIdentifier',
86+
});
87+
}
88+
89+
// Check content-based validations
90+
const contentValue = getAttrText('content');
91+
92+
if (hasContent && typeof contentValue === 'string') {
93+
// http-equiv="refresh" checks
94+
if (hasHttpEquiv) {
95+
if (contentValue.includes(';')) {
96+
// Redirect: should not have delay > 0
97+
if (contentValue.charAt(0) !== '0') {
98+
context.report({
99+
node,
100+
messageId: 'metaRefreshRedirect',
101+
});
102+
}
103+
} else {
104+
// Plain refresh: delay should be > 72000
105+
const delay = Number.parseInt(contentValue, 10);
106+
if (delay <= 72_000) {
107+
context.report({
108+
node,
109+
messageId: 'metaRefreshDelay',
110+
});
111+
}
112+
}
113+
}
114+
115+
// Viewport checks
116+
const userScalableRegExp = /user-scalable(\s*?)=(\s*?)no/gim;
117+
if (userScalableRegExp.test(contentValue)) {
118+
context.report({
119+
node,
120+
messageId: 'viewportUserScalable',
121+
});
122+
}
123+
124+
if (contentValue.includes('maximum-scale')) {
125+
context.report({
126+
node,
127+
messageId: 'viewportMaximumScale',
128+
});
129+
}
130+
}
131+
},
132+
};
133+
},
134+
};

0 commit comments

Comments
 (0)