Dev.to WebDev 🛠 Dev 👁 0 📖 8 min read

Ampersands, Quotes and Double-Encoding: Escaping og:title and og:description Exactly Once

A shop owner shares a link to a page called Fish & Chips: "Best" in Town. In the chat, the card reads Fish & Chips: "Best" in Town. On another page, the title just stops at The, because a straight quote end

A shop owner shares a link to a page called Fish & Chips: "Best" in Town. In the chat, the card reads Fish & Chips: "Best" in Town. On another page, the title just stops at The, because a straight quote ended the attribute early.

Neither of these is a platform bug. Both come from the same place: the text was escaped the wrong number of times on its way into a <meta> tag. Once too many and the reader sees entity codes. Zero times and a quote breaks the HTML. This post walks through why it happens, a small helper that escapes exactly once, and a checker you can run on any page's HTML. Everything here is plain JavaScript with Node's built-in test runner, no packages.

How a value travels into a share tag

Share text rarely starts life as a clean string. Look at where og:title and og:description usually come from:

  • a CMS title field, which may store Fish & Chips or Fish &amp; Chips depending on the editor
  • an excerpt generated from rich text, which still has <p>, <strong> and &nbsp; in it
  • a product feed or spreadsheet import, where someone already "made it HTML-safe"
  • a template engine or a framework like React or the Next.js metadata API, which escapes attribute values for you

Every one of those steps can escape. If two of them do, you get the &amp;amp; card. HTML parsing is strict about this: &amp;amp; decodes once, to the literal text &amp;, and that's what a crawler that reads the tag will hand to the card. Nothing downstream is going to "fix" it for you.

The opposite failure is quieter. A raw " inside a double-quoted attribute ends the value:

<meta property="og:title" content="The "Best" Chips in Town">

A parser reads content as The and treats Best" and the rest as junk attributes. The card shows a one-word title, or falls back to something else, depending on the app.

The rule: plain text in the middle, escape once at the edge

The fix is a boundary rule, not a cleverer escape function:

  1. Normalise to plain text once, as soon as the value enters your code. Strip tags, decode entities one time, collapse whitespace.
  2. Keep it plain through every function, cache and API in between.
  3. Escape exactly once, at the last moment, by whatever writes the HTML.

Step 3 has a catch. If you render with React, JSX, the Next.js metadata API or most template engines with auto-escaping, they are the edge, so you pass plain text and do not escape yourself. Only escape by hand when you're building HTML strings, for example in a static site script or an edge function that rewrites HTML.

The helper

Here's the whole thing. It decodes the entities that turn up in real CMS output (the basic five, &nbsp;, curly quotes, dashes, ellipsis, plus numeric and hex references) and leaves unknown names alone rather than guessing.

// share-text.mjs: turn whatever the CMS gives you into one escaped attribute value.
const NAMED = {
  amp: '&', lt: '<', gt: '>', quot: '"', apos: "'", nbsp: '\u00a0',
  ndash: '\u2013', mdash: '\u2014', hellip: '\u2026', lsquo: '\u2018', rsquo: '\u2019',
  ldquo: '\u201c', rdquo: '\u201d', copy: '\u00a9', reg: '\u00ae', trade: '\u2122',
};

export function decodeEntities(s) {
  return s.replace(/&(#[xX][0-9a-fA-F]+|#\d+|[a-zA-Z]+);/g, (m, e) => {
    if (e[0] === '#') {
      const hex = e[1] === 'x' || e[1] === 'X';
      const cp = parseInt(e.slice(hex ? 2 : 1), hex ? 16 : 10);
      return cp > 0 && cp <= 0x10ffff ? String.fromCodePoint(cp) : m;
    }
    return NAMED[e] ?? m; // unknown names stay as they are
  });
}

// Plain text, once: drop tags, decode entities a single time, collapse whitespace.
export function toPlainText(raw) {
  const noTags = String(raw).replace(/<[^>]*>/g, ' ');
  return decodeEntities(noTags)
    .replace(/\s+/g, ' ')
    .replace(/ ([,.;:!?])/g, '$1') // "</strong>," left a space before the comma
    .trim();
}

// Escape for a double-quoted attribute. Run this exactly once, at the very end.
export function escapeAttr(text) {
  return text
    .replace(/&/g, '&amp;')
    .replace(/"/g, '&quot;')
    .replace(/</g, '&lt;')
    .replace(/>/g, '&gt;');
}

export function metaTag(attr, key, raw) {
  return `<meta ${attr}="${key}" content="${escapeAttr(toPlainText(raw))}">`;
}

Three details are deliberate:

  • Decoding runs once. Fish &amp;amp; Chips becomes Fish &amp; Chips, not Fish & Chips. If text arrives double-escaped, the honest fix is upstream, and the checker below will point at it. Decoding in a loop until nothing changes would also mangle a page that's genuinely about HTML entities.
  • & is escaped first in escapeAttr. Do it later and you escape your own &quot; into &amp;quot;, which is the bug we're trying to remove.
  • Bad code points are kept as text. &#0; or an out-of-range hex value would throw in String.fromCodePoint, so they're returned unchanged.

Usage when you build the head yourself:

import { metaTag, toPlainText } from './share-text.mjs';

const post = { title: 'Fish &amp; Chips: "Best" in Town', excerpt: '<p>Open <strong>late</strong>,&nbsp;every day.</p>' };

console.log(metaTag('property', 'og:title', post.title));
// <meta property="og:title" content="Fish &amp; Chips: &quot;Best&quot; in Town">

console.log(metaTag('property', 'og:description', post.excerpt));
// <meta property="og:description" content="Open late, every day.">

And in a framework that escapes for you, stop at plain text:

// Next.js App Router: the metadata API writes the tags and escapes them.
export async function generateMetadata() {
  const post = await getPost();
  return {
    title: toPlainText(post.title),
    openGraph: { title: toPlainText(post.title), description: toPlainText(post.excerpt) },
  };
}

If you passed escapeAttr(...) there instead, the framework would escape your &amp; again and you'd be back to &amp;amp;.

A checker for pages you didn't build

You often find this bug on somebody else's site, or on a page whose template you can't easily read. This checker scans the raw HTML for share tags and reports two things: an entity that was escaped twice, and text left over after the content value, which is what a stray quote produces.

// check-head.mjs: flag share tags that were escaped twice or broken by a stray quote.
const SHARE = /^(og:|twitter:|description$)/;
const DOUBLE = /&amp;(amp|quot|lt|gt|apos|nbsp|#\d+|#x[0-9a-f]+);/i;

export function checkHead(html) {
  const problems = [];
  for (const [tag] of html.matchAll(/<meta\b[^>]*>/gi)) {
    const key = /(?:property|name)\s*=\s*"([^"]*)"/i.exec(tag)?.[1];
    if (!key || !SHARE.test(key)) continue;
    const m = /content\s*=\s*"([^"]*)"/i.exec(tag);
    if (!m) { problems.push({ key, issue: 'no double-quoted content' }); continue; }
    const rest = tag.slice(m.index + m[0].length).replace(/\/?>$/, '').trim();
    if (rest && !/^[\w:-]+\s*=\s*"[^"]*"(\s+[\w:-]+\s*=\s*"[^"]*")*$/.test(rest)) {
      problems.push({ key, issue: 'text after content: a stray quote probably cut the value', rest });
    }
    if (DOUBLE.test(m[1])) problems.push({ key, issue: 'double-encoded entity', value: m[1] });
  }
  return problems;
}

It's regex-based on purpose: it only has to read <meta> tags, and it should run anywhere with no install. Feed it the HTML the server sends, not what you see in DevTools, because the browser has already parsed and "fixed" the DOM by then:

// check.mjs
import { checkHead } from './check-head.mjs';
const html = await (await fetch(process.argv[2])).text();
console.table(checkHead(html));
node check.mjs https://example.com/menu

Tests

These run with node --test on Node 18 or later. They pin the behaviour that matters: escape once, don't re-escape, keep Urdu and emoji untouched, and catch both failures in the checker.

import { test } from 'node:test';
import assert from 'node:assert/strict';
import { decodeEntities, toPlainText, escapeAttr, metaTag } from './share-text.mjs';
import { checkHead } from './check-head.mjs';

test('plain text from the CMS is escaped once', () => {
  assert.equal(metaTag('property', 'og:title', 'Fish & Chips: "Best" in town'),
    '<meta property="og:title" content="Fish &amp; Chips: &quot;Best&quot; in town">');
});

test('already-escaped text is not escaped again', () => {
  assert.equal(metaTag('property', 'og:title', 'Fish &amp; Chips'),
    '<meta property="og:title" content="Fish &amp; Chips">');
});

test('rich text loses its tags, keeps its words', () => {
  assert.equal(toPlainText('<p>Open <strong>9&ndash;5</strong>, Mon&#8211;Sat</p>'),
    'Open 9\u20135, Mon\u2013Sat');
});

test('Urdu and emoji pass through untouched', () => {
  assert.equal(toPlainText('کراچی کی بہترین چائے ☕'), 'کراچی کی بہترین چائے ☕');
});

test('checker finds double encoding', () => {
  const p = checkHead('<meta property="og:title" content="Fish &amp;amp; Chips">');
  assert.equal(p[0].issue, 'double-encoded entity');
});

test('checker finds a value cut short by a raw quote', () => {
  const p = checkHead('<meta property="og:title" content="The "Best" Chips">');
  assert.equal(p[0].issue, 'text after content: a stray quote probably cut the value');
});

The full test file has a few more cases (numeric and hex references, unknown entity names, a clean head that should produce no warnings).

Where to look when you see it

If a card shows entity codes, work backwards from the HTML:

  1. View the raw response (curl -s URL | grep -i 'og:title'). If it says &amp;amp;, the page is wrong, not the app.
  2. Check whether the stored value already contains &amp;. Rich-text editors and imports are the usual suspects.
  3. Find every place that escapes on the way out. If a framework writes the tag, remove your own escaping.
  4. Re-check the card in a preview tool. I use OG Scope, a free link preview checker I built, which shows the title and description as read from the live HTML next to estimated cards; its guide to what an Open Graph preview uses covers the other tags worth a look while you're there.

Then re-share. Apps that already cached the broken card may keep showing it for a while, so a fresh share, or the platform's own refresh tool where one exists, is part of the fix.

The pattern is worth remembering beyond share tags: decode once on the way in, keep plain text in the middle, escape once on the way out. Most "weird characters" bugs are a step that broke that rule.

📰 Read the original article on Dev.to WebDev

Originally published by Dev.to WebDev. Aggregated on AIWithGhost for educational purposes — full credit and traffic to the original publisher.