✎ ShareNotes
2026-11-06 · Engineering · By the ShareNotes team

A raw link that was secretly HTML: serving plain text from a rich-text editor

Every ShareNotes note now has a plain-text link at /<name>/raw. Building it meant finding out that our old raw endpoint returned HTML for any note written in the browser, and deciding what “plain text” from a rich-text editor should even mean.

A "raw" link is one of the oldest conventions in pastebins: a URL that returns the text and nothing else, as text/plain, so a script or an importer can fetch it. ShareNotes had one, tucked away at /api/v1/notes/<name>/raw. Then someone asked us, through the Suggestion box, for a way to link straight to a note's raw text. Looking into it, we found our raw link was not always returning text at all.

What the raw link actually returned

ShareNotes notes come from two places, and they are stored differently.

  • Notes sent through the API or the MCP server are stored exactly as sent.
  • Notes written on the website come from a rich-text editor, a contenteditable element. We store what that element contains, which is HTML.

The raw endpoint returned whatever was stored. For an API note, that was right. For a note typed in the editor, it was HTML with the right Content-Type on top.

The worst case is code pasted from a site that colours it. The editor keeps the colouring, so a one-line JSON object like {"a":1} is stored as something like this:

<span class="br0">{</span><span class="st0">&quot;a&quot;</span><span class="sy0">:</span><span class="nu0">1</span><span class="br0">}</span>

Fetch that through a "raw" link and feed it to a JSON parser, and the parser gives up at the first character. The note looked fine in the browser. Only the raw link was broken, which is exactly the link a program uses.

Knowing which notes are HTML

The obvious fix is "if it looks like HTML, convert it". That breaks the other half of our users: someone who uses curl to post an HTML file, a JSX snippet or an XML config through the API expects those tags back byte for byte. Stripping them would be its own bug.

So each note now records where its text came from. A new content_format column says html for notes from the editor and text for notes from the API. Editing a note in the browser makes it html, because the editor saves HTML. API notes are never converted.

Older notes have no value in that column, so for them we guess: tags or entities the editor writes mean HTML, and a whole document starting with <!doctype or <html> means text, because the editor never stores one of those. The guess only applies to notes saved before this change.

Turning editor HTML back into the text you see

The target is simple to state: the raw link should return the lines you see in the note. Getting there means making the decisions a browser makes when it lays the note out.

  • Block elements such as <div> and <p> start a new line, and <br> ends one.
  • A <br> at the very end of a block does not add an empty line. The editor writes <div>text<br></div> a lot, and a browser shows one line there, not two.
  • Runs of spaces and newlines in the HTML collapse to one space, the way CSS collapses them, except inside <pre> or an inline white-space: pre style.
  • &nbsp; becomes a plain space and is never collapsed. This is how the editor stores typed indentation, so it decides whether your Python still runs after the round trip.
  • Entities are decoded, and scripts, styles and hidden elements are skipped.

Why not use the browser's own innerText? There is no browser on the server, and innerText is not quite what you see either. In Chrome, a blank line typed in the editor comes out of innerText as two, because the empty <div><br></div> counts both as a block and as a line break. In code, that doubles every blank line. We wanted the text on the screen, not the text an API happens to return.

The server parses the stored HTML with htmlparser2 and feeds each tag and piece of text to a small layout class that applies the rules above. The same class, as a byte-identical copy, runs in the browser. A test fails if the two copies ever differ.

The same rules in the note itself

Writing the rules down showed us a bug in the note view. When a note has no formatting and looks like code, the viewer shows it as one syntax-highlighted block. It built that block from the note's textContent, which ignores <div> boundaries. So code typed line by line in the editor came back as one long line, and Copy text and Download copied that one line.

Code pasted from elsewhere has the opposite shape: its line breaks are real newline characters inside the HTML, which normal layout would collapse. The code view now asks the shared layout for its lines and keeps whichever source has more of them, so typed code and pasted code both keep theirs. The raw link makes the same choice, so the two agree.

One two-segment route in a server that 404s everything else

The raw link people asked for is short: sharenotes.dev/<name>/raw. Our front-end server deliberately answers 404 for any address with two or more path segments that is not a real page, so that made-up URLs don't turn into indexable copies of the app. The raw route is now the one exception, and it is matched narrowly:

location ~ ^/([A-Za-z0-9-]+)/raw$ {
    resolver 127.0.0.11 valid=30s ipv6=off;
    set $raw_backend sharenote-backend:3001;
    proxy_pass http://$raw_backend/api/v1/notes/$1/raw;
}
  • The character class is the alphabet our note names use, so nothing unexpected reaches the URL we proxy to. /name/raw/, /name/RAW and /a/raw/b still get a 404.
  • The backend's address is looked up per request. With a fixed upstream name, nginx refuses to start while the backend container is down, and this server delivers every page of the site. A test starts it with no backend at all and checks the site still loads.
  • The app's service worker would answer /name/raw with the app itself for anyone who has visited before, so the route is on its list of addresses to leave to the network.

What the raw link never opens

A raw link is easy to paste into a script, a chat or a README. That makes it a convenient way to leak a note that was meant to stay contained, so it obeys every rule the note itself does.

  • Notes locked with a PIN for viewing return 401. The PIN is entered in a browser, and scripts never have it.
  • Notes that delete after the first read are used up by a raw read, like any other read. Link-preview bots in chat apps get an empty body and don't use the note up, and the browser that made the note doesn't either. The Raw button is not shown on these notes, so nobody spends the one read by accident.
  • Every raw response is sent with X-Robots-Tag: noindex, nofollow and Cache-Control: no-store.

One more practical detail: converting a large note is not free. In our tests, 10 MB of editor HTML took about a second, and a server spending a second on one request is a server not answering anyone else. Large conversions are cached by note and last-edit time, so fetching the same raw link again costs nothing, and an edit replaces the cached copy.

No sign-up · Free forever Write a note →

Answers, briefly.

The questions this post gets asked most.

How do I get a raw link for a ShareNotes note?
Add /raw to the note's address, for example https://sharenotes.dev/blue-fox-42/raw, or press the Raw button on the note. The response is text/plain with only the note's text.
Does the raw link return HTML for notes written in the editor?
No. Notes written in the editor come back as the text you see in the note, with line breaks and indentation kept and formatting dropped. Notes sent through the API come back exactly as they were sent, tags included.
Can I use the raw link for a PIN-protected or one-time note?
A note locked with a PIN for viewing returns 401. A note that deletes after the first read is used up by a raw read, except by link-preview bots and the browser that made it.
Will raw links show up in search results?
No. Raw responses are sent with X-Robots-Tag: noindex, nofollow and are never cached.