Repository: apostrophecms/sanitize-html (js)

Issue:
Allow hooking into onopentag and onclosetag
## The problem to solve

I'm trying to solve the issue described here https://github.com/apostrophecms/sanitize-html/issues/49. I think I'm quite close to doing it correctly, but current `sanitize-html` functions and configuration seem too limiting to express this.

Imagine performing the following two modifications to HTML:
1. insert a space before the first text block after a disallowed element has just opened
2. insert a space before the first text block after a disallowed element has just closed

For example (assume we disallow all `<div>`):
```html
<div>hello</div>there -> "hello there"
hello<div>there</div> -> "hello there"
<div>hello</div><div>there</div> -> " hello there"
<div><div>hello</div>there</div> -> " hello there"
```

Especially note scenarios like this (where we can't simply add a space before each closing `</div>`):
```html
<div><text>this is a text block</text><text>this is a text block</text></div>
```

I don't think this is currently possible (but correct me if I'm wrong).

## Proposed solution

Allow hooking into `ontagopen` and `ontagclose` events and just pass `tag` names into the listeners like this:
```js
sanitizeHtml(
  '<p>This is <a href="http://www.linux.org"></a><br/>Linux</p>',
  {
    onOpenTag: function(tag) {
      console.log(`${tag} opened`);
    },
    onCloseTag: function(tag) {
      console.log(`${tag} closed`);
    },
  }
);
```

## Alternatives

Tried all sorts of hacky ways of using `transformTags` and `exclusiveFilter` and `textFilter` but in combination with `discard` it doesn't work.

Maybe an alternative could be if `exclusiveFilter` could allow applying the `discard` mode, then it *may* be possible to hook into the `onclosetag` indirectly. But that's hacky.


Pull request description:
feat: add tag open, tag close, and text events
<!-- Please don't delete this template -->

<!-- PULL REQUEST TEMPLATE -->
<!-- (Update "[ ]" to "[x]" to check a box) -->

## Summary

Allows hooking into tag tag open, tag close, and text events of the parser to enable more advanced transformations such as inserting a space between two removed block-level tags with preserved text.

Closes #691.

## What are the specific steps to test this change?

Added a simple test in the test suite, but you can also test manually by passing the `onOpenTag`, `onCloseTag`, and `onText` listeners in the sanitizer options.

## What kind of change does this PR introduce?
*(Check at least one)*

- [ ] Bug fix
- [x] New feature
- [ ] Refactor
- [ ] Documentation
- [ ] Build-related changes
- [ ] Other

## Make sure the PR fulfills these requirements:

- [x] It includes a) the existing issue ID being resolved, b) a convincing reason for adding this feature, or c) a clear description of the bug it resolves
- [ ] The changelog is updated
- [ ] Related documentation has been updated
- [x] Related tests have been updated

If adding a new feature without an already open issue, it's best to open a **feature request issue** first and wait for approval before working on it.


Patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 23ce172..1796abe 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,9 @@
 # Changelog
 
+## UNRELEASED
+
+- Add `onOpenTag` and `onCloseTag` events to enable advanced filtering to hook into the parser.
+
 ## 2.15.0 (2025-03-19)
 
 - Allow keeping tag content when discarding with exclusive filter by returning `"excludeTag"`. Thanks to [rChaoz](https://github.com/rChaoz).
diff --git a/README.md b/README.md
index ec2b688..903c95e 100644
--- a/README.md
+++ b/README.md
@@ -781,6 +781,50 @@ nestingLimit: 6
 
 This will prevent the user from nesting tags more than 6 levels deep. Tags deeper than that are stripped out exactly as if they were disallowed. Note that this means text is preserved in the usual ways where appropriate.
 
+### Advanced filtering
+
+For more advanced filtering you can hook directly into the parsing process using tag open and tag close events.
+
+The `onOpenTag` event is triggered when an opening tag is encountered. It has two arguments:
+- `tagName`: The name of the tag.
+- `attribs`: An object containing the tag's attributes, e.g. `{ src: "/path/to/tux.png" }`.
+
+The `onCloseTag` event is triggered when a closing tag is encountered. It has the following arguments:
+- `tagName`: The name of the tag.
+- `isImplied`: A boolean indicating whether the closing tag is implied (e.g. `<p>foo<p>bar`) or explicit (e.g. `<p>foo</p><p>bar</p>`).
+
+For example, you may want to add spaces around a removed tag, like this:
+```js
+const allowedTags = [ 'b' ];
+let addSpace = false;
+const sanitizedHtml = sanitizeHtml(
+  'There should be<div><p>spaces</p></div>between <b>these</b> words.',
+  {
+    allowedTags,
+    onOpenTag: (tagName, attribs) => {
+      addSpace = !allowedTags.includes(tagName);
+    },
+    onCloseTag: (tagName, isImplied) => {
+      addSpace = !allowedTags.includes(tagName);
+    },
+    textFilter: (text) => {
+      if (addSpace) {
+        addSpace = false;
+        return ' ' + text;
+      }
+      return text;
+    }
+  }
+);
+```
+
+In this example, we are setting a flag when a tag that will be removed has been opened or closed. Then we use the `textFilter` to modify the text to include spaces. The example should produce:
+```
+There should be spaces between <b>these</b> words.
+```
+
+This is a simplified example that is not meant to be production-ready. For your specific case, you may want to keep track of currently open tags, using the open and close events to push and pop items on the stack, or only insert spaces next to a subset of disallowed tags.
+
 ## About ApostropheCMS
 
 sanitize-html was created at [P'unk Avenue](https://punkave.com) for use in [ApostropheCMS](https://apostrophecms.com), an open-source content management system built on Node.js. If you like sanitize-html you should definitely check out ApostropheCMS.
diff --git a/index.js b/index.js
index 1d65205..ebb741e 100644
--- a/index.js
+++ b/index.js
@@ -220,6 +220,10 @@ function sanitizeHtml(html, options, _recursing) {
 
   const parser = new htmlparser.Parser({
     onopentag: function(name, attribs) {
+      if (options.onOpenTag) {
+        options.onOpenTag(name, attribs);
+      }
+
       // If `enforceHtmlBoundary` is `true` and this has found the opening
       // `html` tag, reset the state.
       if (options.enforceHtmlBoundary && name === 'html') {
@@ -544,6 +548,9 @@ function sanitizeHtml(html, options, _recursing) {
       }
     },
     onclosetag: function(name, isImplied) {
+      if (options.onCloseTag) {
+        options.onCloseTag(name, isImplied);
+      }
 
       if (skipText) {
         skipTextDepth--;


Test patch:
diff --git a/test/test.js b/test/test.js
index e1c386b..efd02ce 100644
--- a/test/test.js
+++ b/test/test.js
@@ -1767,4 +1767,48 @@ describe('sanitizeHtml', function() {
 
     assert.equal(sanitizedHtml, expectedOutput);
   });
+  it('should call onOpenTag and onCloseTag callbacks', () => {
+    const onOpenTag = sinon.spy();
+    const onCloseTag = sinon.spy();
+    const inputHtml = '<div id="one">Some Text<p id="two">paragraph content</p><p id="three">some text</div>';
+    sanitizeHtml(inputHtml, {
+      allowedTags: [ 'p' ],
+      onOpenTag,
+      onCloseTag
+    });
+    assert.equal(onOpenTag.callCount, 3);
+    assert.equal(onOpenTag.getCall(0).calledWith('div', { id: 'one' }), true);
+    assert.equal(onOpenTag.getCall(1).calledWith('p', { id: 'two' }), true);
+    assert.equal(onOpenTag.getCall(2).calledWith('p', { id: 'three' }), true);
+    assert.equal(onCloseTag.callCount, 3);
+    assert.equal(onCloseTag.getCall(0).calledWith('p', false), true);
+    assert.equal(onCloseTag.getCall(1).calledWith('p', true), true);
+    assert.equal(onCloseTag.getCall(2).calledWith('div', false), true);
+  });
+  it('should insert spaces between removed tags whose content we keep', () => {
+    const inputHtml = 'Text&#39;s here<div>it&#39;s here</div><div><p>it&#39;s there</p></div>and <b>also</b> here';
+    const expectedOutput = 'Text\'s here it\'s here it\'s there and <b>also</b> here';
+    const allowedTags = [ 'b' ];
+    let addSpace = false;
+    const sanitizedHtml = sanitizeHtml(
+      inputHtml,
+      {
+        allowedTags,
+        onOpenTag: (tag) => {
+          addSpace = !allowedTags.includes(tag);
+        },
+        onCloseTag: (tag) => {
+          addSpace = !allowedTags.includes(tag);
+        },
+        textFilter: (text) => {
+          if (addSpace) {
+            addSpace = false;
+            return ' ' + text;
+          }
+          return text;
+        }
+      }
+    );
+    assert.equal(sanitizedHtml, expectedOutput);
+  });
 });