Ghost/ghost/link-replacer/test/LinkReplacer.test.js
Kevin Ansfield b704530d74
🐛 Fixed unexpected conversion of single-quoted attributes in HTML cards (#19727)
closes ENG-627

We were using `cheerio` to parse+modify+serialize our rendered HTML to modify links for member attribution. Cheerio's serializer has a [long-standing issue](https://github.com/cheeriojs/cheerio/issues/720) (that we've [had to deal with before](https://github.com/TryGhost/SDK/issues/124)) where it replaces single-quote attributes with double-quote attributes. That was resulting in broken rendering when content used single-quotes such as in HTML cards that have JSON data inside a `data-` attribute or otherwise used single-quotes to avoid escaping double-quotes in an attribute value.

- swapped the implementation that uses `cheerio` for one that uses `html5parser` to tokenize the html string, from there we can loop over the tokens and replace the href attribute values in the original string without touching any other part of the content. Avoids a full parse+serialize process which is both more costly and can result unexpected content changes due to serializer opinions.
  - fixes the quote change bug
  - uses tokenization directly to avoid cost of building a full AST
- updated Content API Posts snapshot
  - one of our fixtures has a missing closing tag which we're no longer "fixing" with a full parse+serialize step in the link replacer (keeps modified src closer to original and better matches behaviour elsewhere in the app / without member-attribution applied)
  - the link replacer no longer converts `attr=""` to `attr` (these are equivalent in the HTML spec so no change in behaviour other than preserving the original source html)
- added a benchmark test file comparing the two implementations because the link replacer runs on render so it's used in a hot path
  - new implementation has a 3x performance improvement
  - the separate files with the old/new implementations have been cleaned up but I've left the benchmark test file in place for future reference

Benchmark results comparing implementations:

```
❯ node test/benchmark.js

LinkReplacer
├─ cheerio: 5.03K /s ±2.20%
├─ html5parser: 16.5K /s ±0.43%

Completed benchmark in 0.9976526670455933s
┌─────────────┬─────────┬────────────┬─────────┬───────┐
│   (index)   │ percent │ iterations │ current │  max  │
├─────────────┼─────────┼────────────┼─────────┼───────┤
│   cheerio   │   ''    │ '5.03K/s'  │  5037   │ 5037  │
│ html5parser │   ''    │ '16.5K/s'  │  16534  │ 16534 │
└─────────────┴─────────┴────────────┴─────────┴───────┘
```
2024-03-06 09:11:49 +00:00

103 lines
4.9 KiB
JavaScript

const assert = require('assert/strict');
const linkReplacer = require('../lib/link-replacer');
const html5parser = require('html5parser');
const sinon = require('sinon');
describe('LinkReplacementService', function () {
afterEach(function () {
sinon.restore();
});
it('exported', function () {
assert.equal(require('../index'), linkReplacer);
});
describe('replace', function () {
it('Can replace to URL', async function () {
const html = '<a href="http://localhost:2368/dir/path">link</a>';
const expected = '<a href="https://google.com/test-dir?test-query">link</a>';
const replaced = await linkReplacer.replace(html, () => new URL('https://google.com/test-dir?test-query'));
assert.equal(replaced, expected);
});
it('Can replace relative URLs', async function () {
const html = '<a href="dir/path">link</a>';
const expected = '<a href="https://google.com/test-dir/dir/path">link</a>';
const replaced = await linkReplacer.replace(html, u => u, {base: 'https://google.com/test-dir/'});
assert.equal(replaced, expected);
});
it('Can replace relative URLs relative to root domain', async function () {
const html = '<a href="/dir/path">link</a>';
const expected = '<a href="https://google.com/dir/path">link</a>';
const replaced = await linkReplacer.replace(html, u => u, {base: 'https://google.com/test-dir/'});
assert.equal(replaced, expected);
});
it('Can replace fragments relative to base', async function () {
const html = '<a href="#support">link</a>';
const expected = '<a href="https://google.com/test-dir/#support">link</a>';
const replaced = await linkReplacer.replace(html, u => u, {base: 'https://google.com/test-dir/'});
assert.equal(replaced, expected);
});
it('Doesn\'t break weird &map', async function () {
// Refs https://github.com/TryGhost/Team/issues/2666: somehow this gets replaced with https://example.com/test.jpg?test=true↦id=de76 if decoding entities is enabled
const html = '<img src="https://example.com/test.jpg?test=true&map_id=test">';
const expected = '<img src="https://example.com/test.jpg?test=true&map_id=test">';
const replaced = await linkReplacer.replace(html, () => new URL('https://google.com/test-dir?test-query'));
assert.equal(replaced, expected);
});
it('Does not escape HTML characters', async function () {
const html = 'This is a test & this \'should\' not "be" escaped';
const replaced = await linkReplacer.replace(html, () => new URL('https://google.com/test-dir?test-query'));
assert.equal(replaced, html);
});
it('Does escape HTML characters within a link\'s href', async function () {
const html = '<a href="https://www.google.com/maps/d/u/0/viewer?mid&#x3D;1kQUV2O5QQOigaxJUorLUC9LBP4Ibppg&amp;ll&#x3D;37.87151888616819%2C-122.27759691003418&amp;z&#x3D;13">link</a>';
const expected = '<a href="https://www.google.com/maps/d/u/0/viewer?mid=1kQUV2O5QQOigaxJUorLUC9LBP4Ibppg&ll=37.87151888616819%2C-122.27759691003418&z=13">link</a>';
const replaced = await linkReplacer.replace(html, url => new URL(url));
assert.equal(replaced, expected);
});
it('Can replace to string', async function () {
const html = '<a href="http://localhost:2368/dir/path">link</a>';
const expected = '<a href="#valid-string">link</a>';
const replaced = await linkReplacer.replace(html, () => '#valid-string');
assert.equal(replaced, expected);
});
it('Ignores invalid links', async function () {
const html = '<a href="invalid">link</a>';
const expected = '<a href="invalid">link</a>';
const replaced = await linkReplacer.replace(html, () => 'valid');
assert.equal(replaced, expected);
});
it('Ignores parse errors', async function () {
sinon.stub(html5parser, 'tokenize').throws(new Error('test'));
const html = '<a href="http://localhost:2368/dir/path">link</a>';
const replaced = await linkReplacer.replace(html, () => 'valid');
assert.equal(replaced, html);
});
it('Doesn\'t replace single-quote attributes with double-quote', async function () {
const html = '<div data-graph-name=\'The "all-in" cost of a grant\'>Test</div>';
const expected = '<div data-graph-name=\'The "all-in" cost of a grant\'>Test</div>';
const replaced = await linkReplacer.replace(html, () => new URL('https://google.com/test-dir?test-query'));
assert.equal(replaced, expected);
});
});
});