Skip to content
Merged
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
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -214,6 +214,7 @@ rules in templates can be disabled with eslint directives with mustache or html
| [template-no-class-bindings](docs/rules/template-no-class-bindings.md) | disallow passing classBinding or classNameBindings as arguments in templates | | | |
| [template-no-curly-component-invocation](docs/rules/template-no-curly-component-invocation.md) | disallow curly component invocation, use angle bracket syntax instead | | | |
| [template-no-debugger](docs/rules/template-no-debugger.md) | disallow {{debugger}} in templates | | | |
| [template-no-duplicate-id](docs/rules/template-no-duplicate-id.md) | disallow duplicate id attributes | | | |
| [template-no-dynamic-subexpression-invocations](docs/rules/template-no-dynamic-subexpression-invocations.md) | disallow dynamic subexpression invocations | | | |
| [template-no-element-event-actions](docs/rules/template-no-element-event-actions.md) | disallow element event actions (use {{on}} modifier instead) | | | |
| [template-no-forbidden-elements](docs/rules/template-no-forbidden-elements.md) | disallow specific HTML elements | | | |
Expand Down
46 changes: 46 additions & 0 deletions docs/rules/template-no-duplicate-id.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
# ember/template-no-duplicate-id

<!-- end auto-generated rule header -->

Valid HTML requires that `id` attribute values are unique.

This rule does a basic check to ensure that `id` attribute values are not the same.

## Examples

This rule **forbids** the following:

```gjs
<template><div id='id-00'></div><div id='id-00'></div></template>
```

This rule **allows** the following:

```gjs
<template><div id={{this.divId}}></div></template>
```

```gjs
<template><div id='concat-{{this.divId}}'></div></template>
```

```gjs
<template>
<MyComponent as |inputProperties|>
<Input id={{inputProperties.id}} />
<div id={{inputProperties.abc}} />
</MyComponent>

<MyComponent as |inputProperties|>
<Input id={{inputProperties.id}} />
</MyComponent>
</template>
```

## Migration

For best results, it is recommended to generate `id` attribute values when they are needed, to ensure that they are not duplicates.

## References

- <https://www.w3.org/TR/2011/WD-html5-20110525/elements.html#the-id-attribute>
252 changes: 252 additions & 0 deletions lib/rules/template-no-duplicate-id.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,252 @@
const IGNORE_IDS = new Set(['{{unique-id}}', '{{(unique-id)}}']);

function isControlFlowHelper(node) {
if (node.type === 'GlimmerBlockStatement' && node.path?.type === 'GlimmerPathExpression') {
return ['if', 'unless', 'each', 'each-in', 'let', 'with'].includes(node.path.original);
}
return false;
}

function isIfUnless(node) {
if (node.type === 'GlimmerBlockStatement' && node.path?.type === 'GlimmerPathExpression') {
return ['if', 'unless'].includes(node.path.original);
}
return false;
}

// Walk up the parent chain to find an ancestor element/block whose blockParams
// include headName. Used to make block-param-derived IDs unique per invocation.
function findBlockParamAncestor(node, headName) {
if (!headName) {
return null;
}
let p = node.parent;
while (p) {
if (p.type === 'GlimmerElementNode' && p.blockParams?.includes(headName)) {
return p;
}
if (p.type === 'GlimmerBlockStatement' && p.program?.blockParams?.includes(headName)) {
return p;
}
p = p.parent;
}
return null;
}

/** @type {import('eslint').Rule.RuleModule} */
module.exports = {
meta: {
type: 'problem',
docs: {
description: 'disallow duplicate id attributes',
category: 'Best Practices',
url: 'https://github.com/ember-cli/eslint-plugin-ember/tree/master/docs/rules/template-no-duplicate-id.md',
templateMode: 'both',
},
schema: [],
messages: { duplicate: 'ID attribute values must be unique' },
originallyFrom: {
name: 'ember-template-lint',
rule: 'lib/rules/no-duplicate-id.js',
docs: 'docs/rule/no-duplicate-id.md',
tests: 'test/unit/rules/no-duplicate-id-test.js',
},
},
create(context) {
const sourceCode = context.getSourceCode();
let seenIdStack = [];
let conditionalStack = [];

function enterTemplate() {
seenIdStack = [new Set()];
conditionalStack = [];
}

function isDuplicateId(id) {
for (const seenIds of seenIdStack) {
if (seenIds.has(id)) {
return true;
}
}
return false;
}

function addId(id) {
seenIdStack.at(-1).add(id);
if (conditionalStack.length > 0) {
conditionalStack.at(-1).add(id);
}
}

function enterConditional() {
conditionalStack.push(new Set());
}

function exitConditional() {
const idsInConditional = conditionalStack.pop();
if (conditionalStack.length > 0) {
for (const id of idsInConditional) {
conditionalStack.at(-1).add(id);
}
} else {
seenIdStack.push(idsInConditional);
}
}

function enterConditionalBranch() {
seenIdStack.push(new Set());
}

function exitConditionalBranch() {
seenIdStack.pop();
}

// For a GlimmerMustacheStatement whose path is a PathExpression, return a
// location-aware key so that the same expression in different component
// invocations (e.g. {{inputProperties.id}} in two <MyComponent> blocks) gets
// a distinct key, while two occurrences inside the SAME block get the same key.
function getMustachePathKey(valueNode) {
const sourceText = sourceCode.getText(valueNode);
if (valueNode.path?.type === 'GlimmerPathExpression') {
const headName = valueNode.path.original.split('.')[0];
const ancestor = findBlockParamAncestor(valueNode, headName);
if (ancestor) {
const loc = ancestor.loc?.start;
const tag = ancestor.tag ?? ancestor.path?.original ?? '';
return `${sourceText}${tag}${loc ? `${loc.line}:${loc.column}` : ''}`;
}
}
return sourceText;
}

function resolveIdValue(valueNode) {
if (!valueNode) {
return null;
}

switch (valueNode.type) {
case 'GlimmerTextNode': {
return valueNode.chars || null;
}
case 'GlimmerStringLiteral': {
return valueNode.value || null;
}
case 'GlimmerMustacheStatement': {
if (valueNode.path?.type === 'GlimmerStringLiteral') {
return valueNode.path.value;
}
return getMustachePathKey(valueNode);
}
case 'GlimmerConcatStatement': {
if (valueNode.parts) {
return valueNode.parts
.map((part) => {
if (part.type === 'GlimmerTextNode') {
return part.chars;
}
if (
part.type === 'GlimmerMustacheStatement' &&
part.path?.type === 'GlimmerStringLiteral'
) {
return part.path.value;
}
if (part.type === 'GlimmerMustacheStatement') {
return getMustachePathKey(part);
}
return sourceCode.getText(part);
})
.join('');
}
return sourceCode.getText(valueNode);
}
default: {
return sourceCode.getText(valueNode);
}
}
}

function logIfDuplicate(reportNode, id) {
if (!id) {
return;
}
if (IGNORE_IDS.has(id)) {
return;
}
if (isDuplicateId(id)) {
context.report({ node: reportNode, messageId: 'duplicate' });
} else {
addId(id);
}
}

return {
GlimmerTemplate() {
enterTemplate();
},
'GlimmerTemplate:exit'() {
seenIdStack = [new Set()];
conditionalStack = [];
},

GlimmerElementNode(node) {
// Note: no blockParams scoping here. Elements with block params (e.g.
// <MyComponent as |foo|>) must not isolate their static IDs — a static
// "shared-id" inside and outside such an element ARE duplicates.
const idAttrNames = new Set(['id', '@id', '@elementId']);
for (const attr of node.attributes || []) {
if (idAttrNames.has(attr.name)) {
const id = resolveIdValue(attr.value);
logIfDuplicate(attr, id);
}
}
},

// Handle hash pairs in mustache/block statements (e.g., {{input elementId="foo"}})
GlimmerMustacheStatement(node) {
if (node.hash && node.hash.pairs) {
for (const pair of node.hash.pairs) {
if (['elementId', 'id'].includes(pair.key)) {
if (pair.value?.type === 'GlimmerStringLiteral') {
logIfDuplicate(pair, pair.value.value);
}
}
}
}
},

GlimmerBlockStatement(node) {
if (isControlFlowHelper(node)) {
enterConditional();
} else if (node.hash && node.hash.pairs) {
for (const pair of node.hash.pairs) {
if (['elementId', 'id'].includes(pair.key)) {
if (pair.value?.type === 'GlimmerStringLiteral') {
logIfDuplicate(pair, pair.value.value);
}
}
}
}
},

'GlimmerBlockStatement:exit'(node) {
if (isControlFlowHelper(node)) {
exitConditional();
}
},

GlimmerBlock(node) {
const parent = node.parent;
if (parent && isIfUnless(parent)) {
enterConditionalBranch();
}
},

'GlimmerBlock:exit'(node) {
const parent = node.parent;
if (parent && isIfUnless(parent)) {
exitConditionalBranch();
}
},
};
},
};
Loading
Loading