Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -584,6 +584,8 @@ If you have any suggestions, ideas, or problems, feel free to [create an issue](

### Creating a New Rule

If your rule inspects template attribute values (e.g. mustache forms like `attr={{X}}` or `attr="{{X}}"`), read [docs/glimmer-attribute-behavior.md](docs/glimmer-attribute-behavior.md) first — Glimmer's actual rendering behavior is non-obvious for several common forms, and the doc has the empirically-verified table.

- [Create an issue](https://github.com/ember-cli/eslint-plugin-ember/issues/new) with a description of the proposed rule
- Create files for the [new rule](https://eslint.org/docs/developer-guide/working-with-rules):
- `lib/rules/new-rule.js` (implementation, see [no-proxies](lib/rules/no-proxies.js) for an example)
Expand Down
330 changes: 330 additions & 0 deletions docs/glimmer-attribute-behavior.md

Large diffs are not rendered by default.

20 changes: 2 additions & 18 deletions lib/rules/template-block-indentation.js
Original file line number Diff line number Diff line change
@@ -1,25 +1,9 @@
'use strict';

const { htmlVoidElements } = require('html-void-elements');
const editorConfigUtil = require('../utils/editorconfig');

const VOID_TAGS = new Set([
'area',
'base',
'br',
'col',
'command',
'embed',
'hr',
'img',
'input',
'keygen',
'link',
'meta',
'param',
'source',
'track',
'wbr',
]);
const VOID_TAGS = new Set(htmlVoidElements);
const IGNORED_ELEMENTS = new Set(['pre', 'script', 'style', 'textarea']);

function isControlChar(char) {
Expand Down
34 changes: 30 additions & 4 deletions lib/rules/template-link-rel-noopener.js
Original file line number Diff line number Diff line change
@@ -1,3 +1,7 @@
'use strict';

const { classifyAttribute } = require('../utils/glimmer-attr-presence');

/** @type {import('eslint').Rule.RuleModule} */
module.exports = {
meta: {
Expand Down Expand Up @@ -27,16 +31,31 @@ module.exports = {
return;
}

// `target` is a plain string attribute. Use classifyAttribute so the
// rule recognizes any source form that renders as `target="_blank"` —
// including `target={{"_blank"}}` (i2 analog) and `target="{{'_blank'}}"`
// (i3 analog) — instead of only the static text-node case.
const targetAttr = node.attributes?.find((a) => a.name === 'target');
if (!targetAttr?.value || targetAttr.value.type !== 'GlimmerTextNode') {
const targetClass = classifyAttribute(targetAttr);
if (targetClass.presence !== 'present' || targetClass.value !== '_blank') {
return;
}
if (targetAttr.value.chars !== '_blank') {

const relAttr = node.attributes?.find((a) => a.name === 'rel');
const relClass = classifyAttribute(relAttr);

// Conservative-skip when rel is present but its runtime value cannot
// be determined statically (e.g., `rel={{this.x}}` or
// `rel="prefix-{{this.y}}"`). Flagging here would be a false positive
// — the runtime value may already include the required tokens. The
// doc cross-attribute observation "Concat is never falsy" guarantees
// that any concat form does render *some* attribute value; it just
// isn't statically extractable.
if (relClass.presence === 'present' && relClass.value === null) {
return;
}

const relAttr = node.attributes?.find((a) => a.name === 'rel');
const relValue = relAttr?.value?.type === 'GlimmerTextNode' ? relAttr.value.chars : '';
const relValue = relClass.value || '';
const hasNoopener = /(?:^|\s)noopener(?:\s|$)/.test(relValue);
const hasNoreferrer = /(?:^|\s)noreferrer(?:\s|$)/.test(relValue);
const hasProperRel = hasNoopener && hasNoreferrer;
Expand All @@ -56,6 +75,13 @@ module.exports = {
const newValue = `${filtered} noopener noreferrer`.trim();
return fixer.replaceText(relAttr.value, `"${newValue}"`);
}
// Don't autofix when rel is present in a non-text form (e.g.,
// bare-string-literal mustache or concat) — replacing/inserting
// would either produce a duplicate `rel` attribute or destroy
// the author's binding. Report-only.
if (relAttr) {
return null;
}
// No rel attribute — insert one before the closing >
const sourceCode = context.sourceCode;
const openTag = sourceCode.getText(node).match(/^<a[^>]*/)[0];
Expand Down
18 changes: 15 additions & 3 deletions lib/rules/template-no-nested-interactive.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

const { isHtmlInteractiveContent } = require('../utils/html-interactive-content');
const { INTERACTIVE_ROLES, COMPOSITE_WIDGET_CHILDREN } = require('../utils/interactive-roles');
const { classifyAttribute } = require('../utils/glimmer-attr-presence');

function hasAttr(node, name) {
return node.attributes?.some((a) => a.name === name);
Expand All @@ -15,6 +16,17 @@ function getTextAttr(node, name) {
return undefined;
}

// Returns true only when `tabindex` will actually render on the element.
// Bare `tabindex={{false}}` / `{{null}}` / `{{undefined}}` cause Glimmer to
// omit the attribute (doc rows t6, t7) — `hasAttr` is wrong because the AST
// node is present but the runtime element has no tabindex. Dynamic values
// (`tabindex={{this.x}}`) classify as 'unknown' and are conservative-skipped
// (we cannot statically prove the runtime keeps the attribute).
function isTabindexEffectivelySet(node) {
const attr = node.attributes?.find((a) => a.name === 'tabindex');
return classifyAttribute(attr).presence === 'present';
}

function getRole(node) {
return getTextAttr(node, 'role');
}
Expand Down Expand Up @@ -148,8 +160,8 @@ module.exports = {
return true;
}

// Check tabindex
if (!ignoreTabindex && hasAttr(node, 'tabindex')) {
// Check tabindex (rendered, not just AST-present — see isTabindexEffectivelySet).
if (!ignoreTabindex && isTabindexEffectivelySet(node)) {
return true;
}

Expand Down Expand Up @@ -206,7 +218,7 @@ module.exports = {
if ((tag === 'img' || tag === 'object') && hasAttr(node, 'usemap')) {
return false;
}
return hasAttr(node, 'tabindex');
return isTabindexEffectivelySet(node);
}

return {
Expand Down
15 changes: 14 additions & 1 deletion lib/rules/template-require-context-role.js
Original file line number Diff line number Diff line change
@@ -1,3 +1,7 @@
'use strict';

const { classifyAttribute } = require('../utils/glimmer-attr-presence');

const ROLES_REQUIRING_CONTEXT = {
cell: ['row'],
listitem: ['group', 'list'],
Expand Down Expand Up @@ -89,9 +93,18 @@ function getRoleFromNode(node) {
return null;
}

// Recognize every source form that renders `aria-hidden="true"` at runtime
// per the verified model (doc rows h3, h7, h12, h14). The previous
// `GlimmerTextNode + chars === 'true'` check missed bare `{{"true"}}` and
// concat forms `"{{true}}"` / `"{{'true'}}"` — all three render identical
// HTML and are interpreted as "hidden" by every assistive tech. Notably,
// bare `{{true}}` is *not* hidden (h5: renders `aria-hidden=""`, contested
// per ARIA spec) — `classifyAttribute` returns value="" for that case so
// the equality check correctly excludes it.
function hasAriaHiddenTrue(node) {
const attr = node.attributes?.find((a) => a.name === 'aria-hidden');
return attr?.value?.type === 'GlimmerTextNode' && attr.value.chars === 'true';
const { presence, value } = classifyAttribute(attr);
return presence === 'present' && value === 'true';
}

/**
Expand Down
30 changes: 26 additions & 4 deletions lib/rules/template-require-mandatory-role-attributes.js
Original file line number Diff line number Diff line change
@@ -1,14 +1,25 @@
'use strict';

const { roles } = require('aria-query');
const { AXObjectRoles, elementAXObjects } = require('axobject-query');
const { classifyAttribute } = require('../utils/glimmer-attr-presence');

// ARIA role values are whitespace-separated tokens compared ASCII-case-insensitively.
// Returns the list of normalised tokens, or undefined when the attribute is
// missing or dynamic.
//
// `role` is a plain string attribute (no boolean coercion — see
// docs/glimmer-attribute-behavior.md cross-attribute observations).
// Recognise every source form that renders a statically-known role string:
// - GlimmerTextNode (i1): `role="button"`
// - bare-mustache string literal (i2 analog): `role={{"button"}}`
// - concat with all-literal parts (i3 analog): `role="{{'button'}}"`
// classifyAttribute resolves all three to the same string value.
function getStaticRolesFromElement(node) {
const roleAttr = node.attributes?.find((attr) => attr.name === 'role');

if (roleAttr?.value?.type === 'GlimmerTextNode') {
return splitRoleTokens(roleAttr.value.chars);
const { presence, value } = classifyAttribute(roleAttr);
if (presence === 'present' && value !== null) {
return splitRoleTokens(value);
}

return undefined;
Expand Down Expand Up @@ -231,8 +242,19 @@ module.exports = {
return;
}

// Per docs/glimmer-attribute-behavior.md cross-attribute observations,
// bare-mustache falsy literals on aria-* attributes (rows h6, h9, h10)
// cause Glimmer to OMIT the attribute at runtime. AST-presence is not
// a proxy for runtime-presence here: an element written as
// <div role="option" aria-selected={{false}}> renders without any
// aria-selected attribute and should NOT be treated as satisfying
// role="option"'s required ARIA state.
const foundAriaAttributes = (node.attributes ?? [])
.filter((attribute) => attribute.name?.startsWith('aria-'))
.filter(
(attribute) =>
attribute.name?.startsWith('aria-') &&
classifyAttribute(attribute).presence !== 'absent'
)
.map((attribute) => attribute.name);

const result = getMissingRequiredAttributes(roleTokens, foundAriaAttributes, node, context);
Expand Down
25 changes: 6 additions & 19 deletions lib/rules/template-self-closing-void-elements.js
Original file line number Diff line number Diff line change
@@ -1,3 +1,9 @@
'use strict';

const { htmlVoidElements } = require('html-void-elements');

const VOID_ELEMENTS = new Set(htmlVoidElements);

/** @type {import('eslint').Rule.RuleModule} */
module.exports = {
meta: {
Expand Down Expand Up @@ -27,25 +33,6 @@ module.exports = {
},

create(context) {
const VOID_ELEMENTS = new Set([
'area',
'base',
'br',
'col',
'command',
'embed',
'hr',
'img',
'input',
'keygen',
'link',
'meta',
'param',
'source',
'track',
'wbr',
]);

const sourceCode = context.sourceCode;
const config = context.options[0] ?? true;

Expand Down
Loading
Loading