Ognjen Regoje bio photo

Ognjen Regoje
But you can call me Oggy


I make things that run on the web (mostly).
More ABOUT me and my PROJECTS.

me@ognjen.io LinkedIn

Editing the source markdown of a generated static site in the browser

#filesystem api #jekyll

This site is generated using Jekyll, which uses Markdown under the hood, just like nearly every other static site generator.

Markdown is great, but it is inconvenient when proofreading or editing. You must jump from editor to browser, wait for it to regenerate the site, and refresh.

Wouldn’t it be great if you could edit the markdown from the generated pages in the browser and have those changes saved in the source file?

That sounds like an excellent use case (excuse) for the Chrome File System Access API.

Conditionally adding the script

Put this code in the post template.

{%if post.with_editor and site.serving%}
  {%include _editor.html%}
{%endif%}

site.serving is set if jekyll s is run but not if jekyll b.

_editor.html is the include where well dump the JavaScript for the editor to be activated.

includes/_editor.html

  <script src="https://unpkg.com/showdown/dist/showdown.min.js"></script>
<script type="module">
  import { get, set } from 'https://cdn.jsdelivr.net/npm/idb-keyval@6/+esm';
  document.addEventListener("DOMContentLoaded",  async () => {
    await start()
  });
  async function start(){
    // showdown converts markdown to html
    const converter = new showdown.Converter()

    // have Jekyll inject the path to the page
    // Replacing the _linked because that's a symlink and
    // it didn't seem to work with links
    const path = "_linked/small/jekyll-editing-rendered-posts-using-chrome-filesystem-api.md".replace('_linked/', '').split('/')

    const filename = path.slice(-1)[0]

    // the directory where the file is
    let dirHandle = null;
    // the source file
    let fileHandle = null

    const init = async function() {
      // check if the dir has been previously saved
      const directoryHandleOrUndefined = await get('blog');
      // if it is, and the browser still has permissions we can immediately set it
      if (directoryHandleOrUndefined && await verifyPermission(directoryHandleOrUndefined)) {
        dirHandle = directoryHandleOrUndefined;
      } else {
        // otherwise we have to show a dir picker
        dirHandle = await window.showDirectoryPicker({ mode: 'readwrite' });
        await set('blog', dirHandle);
      }

      // for traversing
      let index = 0

      // then traverse till we get to the correct path
      while (path[index] !== filename){
        dirHandle = await dirHandle.getDirectoryHandle(path[index]);
        index ++;
      }

      // no error handling yolo, select the right dir the first time ¯\_(ツ)_/¯


      // then get the handle to the actual source file
      fileHandle = await dirHandle.getFileHandle(filename, { write: true });

      // get the raw content
      let content = await getContent(fileHandle)

      // separate out the frontmatter
      let frontmatter = content.split('---')[1]
      // and the markdown
      let markdown = content.split('---')[2]

      // when it's focused, apply a monospace font, and set the content to the markdown
      ar.addEventListener('focus', () => {
        ar.style.whiteSpace = 'pre-wrap';
        ar.style.fontFamily = 'monospace'
        ar.style.fontSize = '18px'
        ar.style.padding = '5px'
        ar.style.lineHeight = "25px"
        ar.innerHTML = markdown.trim()
      })


      // when escape is pressed save (some might want this to be cancel, but for me save worked better)
      ar.addEventListener('keydown', (ev) => {
        if (ev.key === 'Escape') ar.blur()
      })

      // so blur, save the content
      ar.addEventListener('blur', async () => {
        // generate the new html
        const newMarkdown = ar.innerText
        const html = converter.makeHtml(newMarkdown).trim()

        // remove the monospace style
        ar.style.removeProperty('white-space')
        ar.style.removeProperty('font-family')
        ar.style.removeProperty('font-size')
        ar.style.removeProperty('line-height')
        ar.style.removeProperty('padding')

        ar.innerHTML = html

        // write the new markdown
        const writable = await fileHandle.createWritable();
        await writable.write(
          "---\n" +
          frontmatter.trim() + "\n" +
          "---\n" +
          newMarkdown + "\n"
        );
        await writable.close();

        markdown = newMarkdown;
      })

      // enable editing
      ar.setAttribute('contenteditable', true)
      // remove the init handler
      ar.removeEventListener('click', init)
      // focus
      ar.focus()
    }

    // the content container
    const ar = document.getElementById('content')

    // filesystem permissions etc can only be requested after use action
    ar.addEventListener('click', init)
  }


  async function verifyPermission(fileHandle) {
    const options = {mode: 'readwrite'};
    // Check if permission was already granted. If so, return true.
    if ((await fileHandle.queryPermission(options)) === 'granted') {
      return true;
    }
    // Request permission. If the user grants permission, return true.
    if ((await fileHandle.requestPermission(options)) === 'granted') {
      return true;
    }
    // The user didn't grant permission, so return false.
    return false;
  }

  async function getContent (fs) {
    const file = await fs.getFile();
    const content = await file.text();
    return content;
  }
</script>

Potential improvements

  • Stripping formatting on paste – now, I paste using Ctrl + Shift + V
  • Adding syntax highlighting
  • When the post contains liquid tags, such as the {%if site.serving%}... above, those need to be re-rendered by Jekyll. The editor doesn’t break them; it just takes a refresh for them to update.

And if this was interesting, here are some other articles about customizing Jekyll.