Skip to content

Escape <, >, &, U+2028 and U+2029 in the JSON-LD script - #44

Merged
meaningfool merged 1 commit into
mainfrom
claude/upbeat-cerf-2g2a3n
Nov 4, 2026
Merged

meaningfool merged 1 commit into
mainfrom
claude/upbeat-cerf-2g2a3n

Conversation

@meaningfool

@meaningfool meaningfool commented Nov 4, 2026 •

Copy link
Copy Markdown
Owner

Fixes a latent bug in src/components/JsonLd.astro. No current article triggers it in a way a reader can see. This is a technical change: the rendered site is unchanged, apart from one escaped character inside one page's JSON-LD (details below).

Based on main at e8c17cc. That commit is the merge of #43 (the redesign), so this targets main.

The bug

The component wrote JSON.stringify(data) raw into <script type="application/ld+json"> with set:html. JSON.stringify doesn't escape <, > or &. The articleSchema on /articles/<slug>/ holds the title and excerpt from meaningfool-writing, so article text could break the page:

  • Title or excerpt holding </script>: the browser ends the script there. The JSON is cut off, and the rest of it lands in the page as HTML.
  • Title or excerpt holding <!--<script>: this is worse. The script element swallows the rest of the page, including <main> and the footer, up to the next </script>.

The fix

  • src/lib/json-ld.mjs: new serializeJsonLd(data). It runs JSON.stringify(data), then writes <, >, &, U+2028 and U+2029 as the JSON escapes \u003c, \u003e, \u0026, \u2028 and \u2029. The escaped text parses to the same JSON value, so search engines read the same data.
  • src/components/JsonLd.astro: uses serializeJsonLd(data) instead of JSON.stringify(data).
  • tests/json-ld.test.mjs: a test for it. bun run test:documents runs it, since that script runs tests/*.test.mjs.

The function lives in src/lib/ rather than inside the component so the existing node --test setup can import it; node --test can't load .astro files.

Validation

Head commit: 16fd183
Setup:

  • Node 24.22.1 and Bun 1.4.4
  • git submodule update --init --remote, which gave meaningfool-writing at bf783ba
  • bun install

Build and tests

  • bun run build: passes, 62 pages.
  • bun run test:documents: passes, 12 tests and 0 failures (the 10 existing ones plus the 2 new ones).
  • The new escaping test fails when serializeJsonLd is swapped back to plain JSON.stringify, so it catches the bug.
  • CI: the Deploy workflow's build check passed on 16fd183, and the preview is at https://pr-44-meaningfool.josselin-perrus.workers.dev.

dist/ before and after, same content (bf783ba)
Two builds of unchanged main differ only in the Generated: timestamp of llms.txt and llms-full.txt, so that is the noise floor. A script compared the before and after builds file by file, parsing the HTML with parse5:

  • 157 files. 154 are byte-identical, and llms.txt and llms-full.txt differ only in the Generated: timestamp.
  • On all 62 HTML pages, the HTML outside the application/ld+json scripts is byte-identical.
  • All 60 JSON-LD blocks parse to deeply equal values before and after.
  • One script changed, on /articles/structured-extraction/. Its description holds "this <500ms", which the JSON-LD now writes as this \u003c500ms, and that is the only change in it. No other script held one of the escaped characters.

Hostile titles, end to end
I added two temporary articles to the submodule's working tree (never committed, and removed afterwards). I built them with the old component and with the new one, then loaded each page in Chromium (JavaScript off) and with parse5:

Title Old component New component
JSON-LD probe </script><b>x</b>, with a description holding &, U+2028 and U+2029 JSON cut off at "headline":"JSON-LD probe , fails to parse, and the page gets a real <b> element 1 intact JSON-LD block, headline and description parse back exactly, no <b> element
JSON-LD comment probe <!--<script> The script swallows <main> and the footer, and the JSON fails to parse 1 intact JSON-LD block, headline parses back exactly, <main> and the footer in place

After removing the probes, a last build at the head commit matched the validated "after" output, apart from the Generated: timestamps.

Not done

  • The submodule pointer is unchanged, because deploys build the latest content.
  • Not merged. Merging to main deploys the live site, so this waits for the owner's go-ahead.

🤖 Generated with Claude Code

https://claude.ai/code/session_013MdjUcGUV9BPmxbz46Ac8n

JsonLd.astro wrote JSON.stringify(data) raw into
<script type="application/ld+json">. A string holding </script> ended
the element early and put the rest of the JSON into the page as HTML,
and <!--<script> made the element swallow the rest of the page. Article
titles and excerpts come from the content repository, so an article
could trigger it.

serializeJsonLd (src/lib/json-ld.mjs) writes those characters as \u
escapes. The JSON parses to the same value, so search engines read the
same data. tests/json-ld.test.mjs covers it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013MdjUcGUV9BPmxbz46Ac8n
@github-actions

github-actions Bot commented Nov 4, 2026

Copy link
Copy Markdown

Preview: https://pr-44-meaningfool.josselin-perrus.workers.dev

Built from 16fd183 with the latest content. The link stays the same and shows the latest push. Search engines are asked not to index it.

@meaningfool
meaningfool merged commit b14bf96 into main Nov 4, 2026
1 check passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants