Repository: 11ty/eleventy (js)

Issue:
The IdAttributePlugin can create conflicts
### Operating system

macOS Sonoma 14.6.1

### Eleventy

3.0.0-beta.1

### Describe the bug

The plugin does not check for existing `id` attributes in the markup, only ones created for headers previously. That means the following markup (from a real example):

```html
<section id="thank-you">
  <h2>Thank you!</h2>
  <p>…</p>
</section>
```

Will output

```html
<section id="thank-you">
  <h2 id="thank-you">Thank you!</h2>
  <p>…</p>
</section>
```


### Reproduction steps

1. Go to '...'
2. Click on '....'
3. Scroll down to '....'
4. See an error


### Expected behavior

_No response_

### Reproduction URL

_No response_

### Screenshots

_No response_

Pull request:
Fixes #3424, conflicts with existing id attributes from IdAttributePlugin
Automatically resolve conflicts with other elements on the page that have an `id` attribute. Warning: preference given to first `id` found and any subsequent conflicting ids will be renamed.

```html
<div id="testing"></div>
<h1>Testing</h1>
```

becomes

```html
<div id="testing"></div>
<h1 id="testing-2">Testing</h1>
```

but

```html
<h1>Testing</h1>
<div id="testing"></div>
```

becomes

```html
<h1 id="testing">Testing</h1>
<div id="testing-2"></div>
```

Also adds a `filter` callback option to IdAttributePlugin per requested at #3424.

```js
eleventyConfig.addPlugin(IdAttributePlugin, {
	filter: function({ page }) {
		if(page.inputPath.endsWith("test-skipped.html")) {
			return false;
		}
		return true;
	}
});
```


Patch:
diff --git a/src/Plugins/IdAttributePlugin.js b/src/Plugins/IdAttributePlugin.js
index df5e2a2f..1e63f6f9 100644
--- a/src/Plugins/IdAttributePlugin.js
+++ b/src/Plugins/IdAttributePlugin.js
@@ -27,25 +27,37 @@ function IdAttributePlugin(eleventyConfig, options = {}) {
 		options.slugify = MemoizeUtil(slugifyFilter);
 	}
 	if (!options.selector) {
-		options.selector = "h1,h2,h3,h4,h5,h6";
+		options.selector = "[id],h1,h2,h3,h4,h5,h6";
 	}
 	options.decodeEntities = options.decodeEntities ?? true;
 
 	eleventyConfig.htmlTransformer.addPosthtmlPlugin(
 		"html",
-		function (/*pluginOptions = {}*/) {
+		function (pluginOptions = {}) {
+			if (typeof options.filter === "function") {
+				if (options.filter(pluginOptions) === false) {
+					return function () {};
+				}
+			}
+
 			return function (tree) {
 				// One per page
 				let conflictCheck = {};
 
 				tree.match(matchHelper(options.selector), function (node) {
-					if (!node.attrs?.id && node.content) {
+					let id;
+					if (node.attrs?.id) {
+						id = node.attrs?.id;
+					} else if (!node.attrs?.id && node.content) {
 						node.attrs = node.attrs || {};
 						let textContent = getTextNodeContent(node);
 						if (options.decodeEntities) {
 							textContent = decodeHTML(textContent);
 						}
-						let id = options.slugify(textContent);
+						id = options.slugify(textContent);
+					}
+
+					if (id) {
 						if (conflictCheck[id]) {
 							conflictCheck[id]++;
 							id = `${id}-${conflictCheck[id]}`;


Test patch:
diff --git a/test/IdAttributePluginTest.js b/test/IdAttributePluginTest.js
index 6e969f9c..ebd7e859 100644
--- a/test/IdAttributePluginTest.js
+++ b/test/IdAttributePluginTest.js
@@ -12,8 +12,6 @@ test("Using the IdAttribute plugin #3356", async (t) => {
     },
   });
 
-  elev.disableLogger();
-
   let results = await elev.toJSON();
 	t.is(results[0].content, `<h1 id="this-is-a-heading">This is a heading</h1><h2 id="already">This is another heading</h2><h2 id="this-is-another-heading">This is another heading</h2><h3 id="this-is-another-heading-2">This is another heading</h3>`);
 });
@@ -27,8 +25,80 @@ test("Using the IdAttribute plugin with escaped quoted text", async (t) => {
     },
   });
 
-  elev.disableLogger();
-
   let results = await elev.toJSON();
 	t.is(results[0].content.trim(), `<h1 id="this-is-a-heading">This is a <code>&quot;heading&quot;</code></h1>`);
 });
+
+test("Issue #3424, id attribute conflicts (id attribute supplied first)", async (t) => {
+  let elev = new Eleventy("./test/stubs-virtual/", "./test/stubs-virtual/_site", {
+    config: function (eleventyConfig) {
+      eleventyConfig.addPlugin(IdAttributePlugin);
+
+      eleventyConfig.addTemplate("test.html", `<div id="testing"></div><h1>Testing</h1>`, {});
+    },
+  });
+
+  let results = await elev.toJSON();
+	t.is(results[0].content.trim(), `<div id="testing"></div><h1 id="testing-2">Testing</h1>`);
+});
+
+test("Issue #3424, id attribute conflicts (id attribute supplied last)", async (t) => {
+  let elev = new Eleventy("./test/stubs-virtual/", "./test/stubs-virtual/_site", {
+    config: function (eleventyConfig) {
+      eleventyConfig.addPlugin(IdAttributePlugin);
+
+      eleventyConfig.addTemplate("test.html", `<h1>Testing</h1><div id="testing"></div>`, {});
+    },
+  });
+
+  let results = await elev.toJSON();
+	t.is(results[0].content.trim(), `<h1 id="testing">Testing</h1><div id="testing-2"></div>`);
+});
+
+test("Issue #3424, id attribute conflicts (hard coded id conflicts)", async (t) => {
+  let elev = new Eleventy("./test/stubs-virtual/", "./test/stubs-virtual/_site", {
+    config: function (eleventyConfig) {
+      eleventyConfig.addPlugin(IdAttributePlugin);
+
+      eleventyConfig.addTemplate("test.html", `<h1>Testing</h1><h1 id="testing">Testing</h1>`, {});
+    },
+  });
+
+  let results = await elev.toJSON();
+	t.is(results[0].content.trim(), `<h1 id="testing">Testing</h1><h1 id="testing-2">Testing</h1>`);
+});
+
+test("Issue #3424, id attribute conflicts (two hard coded id conflicts)", async (t) => {
+  let elev = new Eleventy("./test/stubs-virtual/", "./test/stubs-virtual/_site", {
+    config: function (eleventyConfig) {
+      eleventyConfig.addPlugin(IdAttributePlugin);
+
+      eleventyConfig.addTemplate("test.html", `<h1 id="testing">Testing</h1><h1 id="testing">Testing</h1>`, {});
+    },
+  });
+
+  let results = await elev.toJSON();
+	t.is(results[0].content.trim(), `<h1 id="testing">Testing</h1><h1 id="testing-2">Testing</h1>`);
+});
+
+test("Issue #3424, filter callback skips", async (t) => {
+  let elev = new Eleventy("./test/stubs-virtual/", "./test/stubs-virtual/_site", {
+    config: function (eleventyConfig) {
+      eleventyConfig.addPlugin(IdAttributePlugin, {
+        filter: function({ page }) {
+          if(page.inputPath.endsWith("test-skipped.html")) {
+            return false;
+          }
+          return true;
+        }
+      });
+
+      eleventyConfig.addTemplate("test.html", `<h1 id="testing">Testing</h1><h1 id="testing">Testing</h1>`, {});
+      eleventyConfig.addTemplate("test-skipped.html", `<h1 id="testing">Testing</h1><h1 id="testing">Testing</h1>`, {});
+    },
+  });
+
+  let results = await elev.toJSON();
+	t.is(results[0].content.trim(), `<h1 id="testing">Testing</h1><h1 id="testing-2">Testing</h1>`);
+	t.is(results[1].content.trim(), `<h1 id="testing">Testing</h1><h1 id="testing">Testing</h1>`);
+});