1.5KB Single-File Wiki
Fedor

Fedor @fedia

Joined:
Feb 4, 2025

1.5KB Single-File Wiki

Publish Date: Mar 1
41 15

Imagine having a personal wiki that fits in a single HTML file — no databases, no servers, just a self-contained knowledge base you can store in Dropbox, email to yourself, or even host on a static file server. Sounds familiar? Inspired by the legendary TiddlyWiki, I set out to create a minimalist wiki that’s lightweight and works even without JavaScript.

TiddlyWiki is a robust and feature-rich tool, but it comes with a cost: it’s a JavaScript-heavy application, often weighing several megabytes. What if we could peel away the complexity and distill it down to its purest form? The result is a lean, fast, and no-frills — a wiki that:

  • Works without JavaScript: Thanks to pure CSS routing, you can view pages even if JS is disabled.
  • Edits with ease: Markdown lets you write and format content effortlessly.
  • Saves instantly: Changes are saved by downloading a new HTML file, making it perfect for offline use.
  • Fits in 1.5 KB: Minified and gzipped, it’s smaller than most favicons.

In this article, I’ll walk you through the key ideas and hacks that made this project possible. Whether you’re a fan of TiddlyWiki, a minimalist at heart, or just curious about building lightweight web apps, there’s something here for you. Let’s dive in!


Pure CSS Routing: Navigation Without JavaScript

The secret sauce lies in the :target pseudo-class, which applies styles to an element whose ID matches the part of the URL after the # (hash). For example, if the URL is #my-page, the element with id="my-page" becomes the "target", and you can style it accordingly.

In our wiki, each page is represented by an <article> element with a unique ID. The CSS rule below ensures that only the target article (or the index page, if no hash is present) is displayed:

article:not(.index, :target, :has(:target)),
:root:has(:target) article.index:not(:has(:target)) {
  display: none;
}
Enter fullscreen mode Exit fullscreen mode

If the URL hash is #my-page, the <article id="my-page"> becomes visible. If the URL has no hash, the .index article is shown by default. If the hash is #my-photo and some article contains <img id="my-photo">, this article will be displayed.

Hash-based routing works purely with CSS, making it incredibly lightweight and reliable.


Markdown Editing and Prerendering

If JavaScript is enabled, the wiki can progressively enhance the experience with features like Markdown editing. The editor is toggled by double-clicking anywhere on the page. Pages are created on the fly when a new hash is encountered.

At first I expected to find a plethora of minimalist Markdown libraries that could handle basic formatting without external dependencies. To my surprise, a relativley popular option — Snarkdown — doesn't support paragraphs. That’s right — no <p> tags!

That led to creating a compact, self-contained Markdown-to-HTML converter. Here’s the core of it:

function md2html(str) {
  const enchtml = (str) => str.replaceAll("<", "&lt;");
  const inlines = [
    [
      /(\*{1,2}|~~|`)(.+?)\1/g,
      (_, c, txt) =>
        c == "*"
          ? `<i>${txt}</i>`
          : c == "**"
          ? `<b>${txt}</b>`
          : c == "~~"
          ? `<s>${txt}</s>`
          : `<code>${enchtml(txt)}</code>`,
    ],
    // ... (other inline patterns)
  ];
  const blocks = [
    [
      /\n(#+)([^\n]+)/g,
      (_, h, txt) => `\n<h${h.length}>${txt.trim()}</h${h.length}>`,
    ],
    [
      /\n(\n *\-[^\n]+)+/g,
      (txt) =>
        `\n<ul><li>${replaceInlines(txt)
          .split(/\n+ *\- */)
          .filter(Boolean)
          .join("</li><li>")}</li></ul>`,
    ],
    // ... (other block patterns)
  ];
  return blocks.reduce((md, rule) => md.replace(...rule), `\n${str}\n`);
}
Enter fullscreen mode Exit fullscreen mode

The md2html function follows a specific sequence to ensure Markdown is parsed correctly:

  1. Process Block-Level Elements First: The function starts by identifying and transforming block-level Markdown syntax (like code blocks, headings, lists, and paragraphs) into their corresponding HTML structures.
  2. Process Inline Elements Within Blocks: Inline formatting rules (like bold, italics, and links) are applied only where appropriate (e.g., inside paragraphs or list items).
  3. Escape HTML Only Where Necessary: HTML escaping is applied selectively, primarily within code blocks and inline code snippets. It's not a security feature here.

The rules are easy to modify or extend, allowing you to add support for additional formatting features. But remember that Markdown is not a replacement for HTML!

When you save a wiki page, the content is prerendered into HTML and dynamically appended to the DOM. It’s essentially like a static site generator, but it operates entirely within the browser resulting in a single, self-contained HTML file.


Saving Wiki: File Download

Since this is a single-file wiki, saving works by downloading the entire HTML file with the updated content. This is achieved using the Blob API:

function download() {
  const doc = document.documentElement.cloneNode(true);
  const html = "<!DOCTYPE html>\n" + doc.outerHTML;
  const blob = new Blob([html], { type: "text/html" });
  const link = document.createElement("a");
  link.href = URL.createObjectURL(blob);
  link.download = "wiki" + Date.now() + ".html";
  link.click();
}
Enter fullscreen mode Exit fullscreen mode

This function creates a new HTML file containing the current state of the wiki and triggers a download. It’s a simple yet effective way to persist changes without a backend.


Conclusion: 1.5KB Single-File Wiki

By combining pure CSS routing, a custom Markdown parser, and a simple offline-first saving method, you can create a functional and lightweight wiki that works even without JavaScript. Whether you're building a personal knowledge base or a portable documentation tool, this approach strikes a nice balance between simplicity and practicality. It’s not perfect, but it gets the job done in a way that’s both elegant and efficient.

Feel free to take the code, tweak it, and make it your own. After all, the best tools are the ones you build yourself.

Comments 15 total

  • Best Codes
    Best CodesMar 1, 2025

    This is very cool! I don't think back / forward navigation works, though.

    • Fedor
      FedorMar 2, 2025

      Seems to be a Codesandbox issue with hash-based nav...

      • Best Codes
        Best CodesMar 2, 2025

        Oh, so it does work, CodeSandbox is the issue! 👍

  • Oisín
    OisínMar 11, 2025

    Very neat! I'm not sure how saving changes works if you don't have Javascript enabled, though.

  • deuxlames
    deuxlamesMar 11, 2025

    That's incredible. I tried tiddlywiki may be 20 years ago :-) and a few days ago i was looking for a markdown wiki that doesn't need a server. Something simple and bingo in my mailbox today !

    I'm going to try right now !

  • Joe Bordes
    Joe BordesMar 11, 2025

    wonderful piece of software! recommendable

  • Adam
    AdamMar 12, 2025

    I love such simple no-dependency and simple ideas! Thanks!

    Added it to the next issue of weeklyfoo.com.

  • Detlef Meyer
    Detlef MeyerMar 12, 2025

    Thanks for sharing. How am I supposed to understand this: "Feel free to take the code, tweak it, and make it your own. After all, the best tools are the ones you build yourself."
    Is this an Unlicense (unlicense.org/)?

    • Paweł Świątkowski
      Paweł ŚwiątkowskiMar 12, 2025

      It's licensed under MIT according to package.json contents.

      • Fedor
        FedorMar 13, 2025

        Right, it's MIT. Thank you!

  • artydev
    artydevMar 12, 2025

    Great, thank you :)

  • Justov Pinkton
    Justov PinktonMar 14, 2025

    This is great! I LOVE TW but this could be useful on my phone, where I want a VERY minimal interface etc. and don't need anything complex, just quick note taking.

    I'm no JS guru - I'm trying to add


    to the md2html and it's driving me crazy. It APPEARS I'm following the pattern of the other inline rules, but it just isn't working. Here's my update to the inlines:
    const inlines = [
    [
      /(\*{1,2}|~~|`)(.+?)\1/g,
      (_, c, txt) =>
        c == "*"
          ? `<i>${txt}</i>`
          : c == "**"
          ? `<b>${txt}</b>`
          : c == "__"
          ? `<hr/>`
          : c == "~~"
          ? `<s>${txt}</s>`
          : `<code>${enchtml(txt)}</code>`,
    ],
    
    Enter fullscreen mode Exit fullscreen mode

    Anyone see what I'm missing?

    Great idea, and thanks in advance!

    • Fedor
      FedorMar 14, 2025

      Here's a casual way you could do that: chatgpt.com/share/67d495a0-3228-80...

      • Justov Pinkton
        Justov PinktonMar 14, 2025

        Ahh within the blocks section... that makes sense. eesh.
        And thanks for expanding to ---, ***, ___.
        I guess I could have hit my AI friend up on this hehe

        I was just going with ___ testing to see if there was a conflict with ---

        That update works!

        Thanks!

  • Mads Hvelplund
    Mads HvelplundMay 25, 2025

    This is pretty neat. It's a pity that it stores all the text twice, once in HTML and once in Markdown, but I'm guessing that is a performance thing?

Add comment