From Plain Text to Structured HTML

When you need to convert raw text into formatted HTML, the usual options are writing Markdown, hand-coding the markup, or reaching for an online converter. Each approach has trade-offs: Markdown adds an abstraction layer, manual HTML requires knowledge most casual users don't have, and online tools are often opaque boxes.

What if you want a simple, self-contained solution that lets users write naturally without learning syntax? You could build a small converter with vanilla JavaScript that handles the most common content elements: headings, paragraphs, links, and images. This is a practical exercise in understanding how text-to-HTML conversion actually works under the hood.

Scoping the Problem

Before writing any code, define what the converter will support. A tool designed for long-form content like blog posts needs only a handful of elements: <h1> for titles, <p> for body text, <a> for links, and <img> for images. Excluding lists, tables, and other elements keeps the implementation focused and the user experience simple.

The requirement that users write without knowing Markdown or HTML is demanding. It means the converter must make sensible assumptions: the first line of input becomes a top-level heading, and each subsequent line becomes a separate paragraph. These heuristics won't cover every case, but they handle the common patterns well enough.

Existing solutions fall short in predictable ways. WYSIWYG editors like TinyMCE bundle extensive functionality — TinyMCE alone ships around 500 KB minified — which is overkill for this narrow task. Open-source alternatives split into two unsatisfying camps: those that only handle headings and paragraphs, and those that actually expect Markdown input rather than true plain text.

Building the Interface

Start with a minimal two-pane layout: a <textarea> for input on the left and a <div> for output on the right. Basic CSS positions the two elements side by side; styling beyond that is cosmetic and not essential to the conversion logic.

<div class="container">
  <textarea id="input" onkeyup="convert(this.value)"></textarea>
  <div id="output"></div>
</div>

The conversion triggers on every onkeyup event, calling a convert() function that takes the textarea's value as its single argument. onkeyup fires after each keystroke, ensuring the output always includes the latest typed character. Using onkeydown instead would cause the output to lag one character behind, which becomes noticeable at sentence endings — the final period wouldn't appear until the user types another key.

Three-Stage Conversion Pipeline

The conversion logic breaks cleanly into three responsibilities, each implemented as a separate function that accepts a string and returns a string:

  1. html_encode() — sanitizes the input against HTML injection
  2. convert_text_to_HTML() — wraps each line in semantic tags
  3. convert_images_and_links_to_HTML() — transforms URLs and image references

Splitting the work this way keeps each function simple, testable, and reusable.

HTML Encoding for Security

JavaScript lacks a built-in HTML encoder, unlike PHP which provides htmlspecialchars() and related functions. Writing one is straightforward. The encoder replaces the five characters that could break out of text content or inject markup:

  • < becomes &lt;
  • > becomes &gt;
  • & becomes &amp;
  • ' becomes &#39;
  • " becomes &quot;

Encoding is a critical security measure — it prevents users from injecting their own HTML or scripts into the output. Note that client-side sanitization is only a deterrent; determined attackers can bypass it. Real protection requires encoding on the server where users cannot interfere. A clean approach is to pass raw, unencoded input from the front end to the back end, then encode once before database insertion.

This raises a storage design question. Two reasonable approaches exist:

  1. Convert-per-request. Store only the HTML-encoded text in the database and run the conversion dynamically each time content is requested. This costs performance per request but makes future output format changes easy.
  2. Convert-once. Run the full conversion before storage, so the database holds final HTML. This is efficient but rigid — updating output requirements later means re-converting stored content.

Whichever approach you choose, be careful not to double-encode. Passing already-encoded text through the encoder again will corrupt the output.

Semantic Tagging Line by Line

With the input sanitized, the next step is wrapping each line in appropriate HTML tags using convert_text_to_HTML(). The function splits the text on the newline character (\n), producing an array of lines that can be evaluated individually.

function convert_text_to_HTML(text) {
  const lines = text.split('\n');
  let html = '';
  
  lines.forEach((line, index) => {
    if (line.trim() === '') return;
    
    if (index === 0) {
      html += `<h1>${line}</h1>`;
    } else {
      html += `<p>${line}</p>`;
    }
  });
  
  return html;
}

This logic skips empty lines, marks the first non-empty line as an <h1> element, and wraps each subsequent line in a <p> tag. The assumption that the first line is a title is reasonable for the blog-post use case. The same pattern readily extends to other content structures — for instance, treating the second line as an author byline with a link to an author archive.

The Final Processing Stage

The last function, convert_images_and_links_to_HTML(), operates on the entire HTML string produced by the previous stage. It scans for URL patterns and wraps them in <a> anchors; file names with common image extensions get replaced with <img> elements.

Building the converter yourself rather than importing a library offers real educational value. You understand precisely what transformations occur at each step, and you can extend the logic to handle additional elements as your requirements evolve.

The second conversion function, convert_images_and_links_to_HTML(), handles turning URLs and image references into proper HTML elements. Unlike the line-based text-to-HTML conversion, this function operates on the entire input as a single string using regular expressions.


function convert_images_and_links_to_HTML(string){
  let urls_unique = [];
  let images_unique = [];
  const urls = string.match(/https*:\/\/[^\s<),]+[^\s<),.]/gmi) ?? [];
  const imgs = string.match(/[^"'>\s]+\.(jpg|jpeg|gif|png|webp)/gmi) ?? [];
                          
  const urls_length = urls.length;
  const images_length = imgs.length;
  
  for (let i = 0; i < urls_length; i++){
    const url = urls[i];
    if (!urls_unique.includes(url)){
      urls_unique.push(url);
    }
  }
  
  for (let i = 0; i < images_length; i++){
    const img = imgs[i];
    if (!images_unique.includes(img)){
      images_unique.push(img);
    }
  }
  
  const urls_unique_length = urls_unique.length;
  const images_unique_length = images_unique.length;
  
  for (let i = 0; i < urls_unique_length; i++){
    const url = urls_unique[i];
    if (images_unique_length === 0 || !images_unique.includes(url)){
      const a_tag = `<a href="${url}" target="_blank">${url}</a>`;
      string = string.replace(url, a_tag);
    }
  }
  
  for (let i = 0; i < images_unique_length; i++){
    const img = images_unique[i];
    const img_tag = `<img src="${img}" alt="">`;
    const img_link = `<a href="${img}">${img_tag}</a>`;
    string = string.replace(img, img_link);
  }
  return string;
}

Regular expressions are the right choice here because URLs typically appear inline within a sentence rather than on their own line. Images may also appear anywhere in the text. Processing everything as one string with a single regex pass per type is more efficient than scanning line-by-line or word-by-word — especially since all this JavaScript runs on every keystroke. That performance consideration matters; regexes carry a cost, so you want to minimize how often you run them.

A quick naming note: images, image, and link are reserved words in JavaScript, so the function uses imgs, img, and a_tag as variable names instead. These particular reserved words don't appear on the MDN reserved words list, but they are flagged on W3Schools.

For each regular expression, the function calls String.prototype.match() and stores the results in an array. The nullish coalescing operator (??) ensures that when no matches are found, the result becomes an empty array instead of null, which would otherwise break downstream logic.

const urls = string.match(/https*:\/\/[^\s<),]+[^\s<),.]/gmi) ?? [];
const imgs = string.match(/[^"'>\s]+\.(jpg|jpeg|gif|png|webp)/gmi) ?? [];

Before doing any replacement, the function filters each result array to keep only unique matches. This step is essential: if the input contains the same URL or image filename multiple times, the replacements would produce broken HTML. JavaScript has no built-in equivalent to PHP's array_unique(), so the code uses a straightforward procedural approach to deduplicate. More elegant functional alternatives exist, but the procedural version is clear and adequate here.

The replacement logic also checks whether a matched URL is actually an image before converting it to an <a> tag. If the URL points to an image, the code skips the link conversion and lets the image handling logic take over. The regexes are intentionally kept simple and readable rather than hyper-precise, trading some accuracy for clarity.

Image filenames in the input, such as my_image.png, become <img src='my_image.png'> in the output. Each image tag is wrapped in an anchor tag that links back to the image file and opens it in a new tab when clicked. This serves two purposes:

  • Viewing the full-size image is easy even when CSS constrains the rendered size — an important real-world consideration.
  • For third-party images, it offers a basic form of attribution. You should still provide explicit credit in a <figcaption> or similar element when possible, but the link at least points back to the source.

On the subject of images, avoid hotlinking external files. Use locally hosted images whenever you can, and provide proper attribution for anything you don't hold the rights to.

One accessibility limitation is worth mentioning: the generated <img> tags include an alt attribute, but its value is left empty. There's no reliable way to auto-generate a meaningful description from a filename. An empty alt can be acceptable for decorative images — though some argue no image is truly decorative — but it's a genuine constraint of this approach.

Rendering the Encoded Output

With both conversion functions ready, displaying the result is straightforward. The hard work is already done, so all that remains is to call the conversion and place the output.

function convert(input_string) {
  output.innerHTML = convert_images_and_links_to_HTML(convert_text_to_HTML(html_encode(input_string)));
}

If you'd rather show the raw HTML markup for inspection, use a <pre> element as the output container instead of a <div>. One key difference: you'd target the <pre> element's textContent, not its innerHTML, so the markup displays as literal text rather than being interpreted.

<pre id='output'></pre>
function convert(input_string) {
  output.textContent = convert_images_and_links_to_HTML(convert_text_to_HTML(html_encode(input_string)));
}

Wrapping Up

That's the whole tool: type plain text into a <textarea>, and the pages parses it line-by-line, encodes the result into HTML, and renders it in a separate element — all with vanilla HTML, CSS, and JavaScript.

See the Pen [Convert Plain Text to HTML (PoC) [forked]](https://codepen.io/smashingmag/pen/yLrxOzP) by Geoff Graham.

See the Pen Convert Plain Text to HTML (PoC) [forked] by Geoff Graham.

This simple build doesn't compete with what a full framework or dedicated library can do, but, it does the job it was designed for cleanly and efficiently. That's often exactly what you need.

If you want to take this further, you could modify the code to POST the textarea content to a server-side PHP script or similar. Extending this project that way is a solid exercise for sharpening your skills.

References

Smashing Editorial