Bliki Phase 4: WYSIWYG Editor

Phase 4 transformed the web editor from a raw textarea into a WYSIWYG experience with a formatting toolbar and live preview. The <bliki-editor> component gained <bliki-toolbar> and <bliki-preview> children, each wired through a new /api/preview endpoint and scoped document events.

The Editor Before Phase 4

Before this phase, <bliki-editor> was a single <article> containing a <textarea> and two buttons — save and cancel. That worked, but editing Markdown required constant mental translation between raw syntax and rendered output. There was no way to format text with buttons, and there was no live preview.

The editable.tmpl partial looked like this:

<bliki-editable content="{{ .Current.FilePath }}">
  {{ content .Current.Content }}
  <button edit hidden>✏️ Edit</button>
  <bliki-editor hidden>
    <article>
      <textarea rows="20"></textarea>
      <button save hidden>Save</button>
      <button cancel hidden>Cancel</button>
    </article>
  </bliki-editor>
  <p error hidden></p>
  <p lock hidden></p>
</bliki-editable>

The <post.tmpl> template didn't include <bliki-editable> at all — blog posts used the blog.tmpl layout, which had no editor support.

New Components

<bliki-toolbar>

The formatting toolbar is a new custom element that wraps selected text with Markdown syntax. It queries its editor via closest('bliki-editor') for access to the textarea:

handleAction(action) {
    const editor = this.closest('bliki-editor');
    if (!editor || !editor.textarea) return;

    const ta = editor.textarea;
    const start = ta.selectionStart;
    const end = ta.selectionEnd;
    const selected = ta.value.substring(start, end);
    const content = this._content;

    const result = wrapMarkdown(action, selected, start, content);
    if (!result) return;

    const text = ta.value.substring(0, start) + result.text + ta.value.substring(end);
    ta.value = text;
    ta.selectionStart = result.cursor;
    ta.selectionEnd = result.cursor;
    ta.focus();
}

The wrapMarkdown function handles the syntax for each action — bold, italic, strikethrough, lists, links, headings, inline code, code blocks, and block quotes. When text is selected, it wraps it (e.g., **selected** for bold). When nothing is selected, it inserts a placeholder (**text**) and positions the cursor inside:

function wrapMarkdown(action, selected, start, path) {
    if (selected.length > 0) {
        switch (action) {
            case 'bold':       return { text: `**${selected}**`, cursor: start + 2 };
            case 'italic':     return { text: `*${selected}*`, cursor: start + 1 };
            case 'strikethrough': return { text: `~~${selected}~~`, cursor: start + 2 };
            case 'code':       return { text: `\`${selected}\``, cursor: start + 1 };
            case 'quote':      return { text: `> ${selected}`, cursor: start + 2 };
            // ... more cases
        }
    }

    // Insert placeholders when nothing is selected
    switch (action) {
        case 'bold':       return { text: `**text**`, cursor: start + 2 };
        case 'italic':     return { text: `*text*`, cursor: start + 1 };
        // ...
    }
}

The toolbar buttons are defined in editable.tmpl:

<bliki-toolbar>
  <div role="group">
    <button bold><small>B</small></button>
    <button italic><small>I</small></button>
    <button strikethrough><small><s>S</s></small></button>
    <button ul><small>•</small></button>
    <button ol><small>1.</small></button>
    <button link><small>🔗</small></button>
    <button heading1><small>H1</small></button>
    <button heading2><small>H2</small></button>
    <button heading3><small>H3</small></button>
    <button code><small>{ }</small></button>
    <button quote><small>"</small></button>
  </div>
</bliki-toolbar>

<bliki-preview>

The preview component provides live Markdown-to-HTML rendering in a side panel. It listens for scoped document events and sends the textarea content to the server via POST /api/preview:

onEditorInput(event) {
    clearTimeout(this._debounceTimer);
    this._debounceTimer = setTimeout(() => {
        this._updatePreview(event.detail.textareaContent);
    }, 300);
}

_updatePreview(content) {
    fetch('/api/preview', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ content })
    })
    .then(r => r.json())
    .then(data => {
        if (this.previewContent) {
            this.previewContent.innerHTML = data.html;
        }
    })
    .catch(() => {
        // Silent failure — preview will just not update
    });
}

A 300ms debounce timer prevents excessive API calls as the user types. The preview stays hidden until the user clicks "Edit" — onEditStart removes the hidden attribute and renders the initial content immediately.

The /api/preview Endpoint

The server-side preview handler lives in internal/handlers/preview.go:

func PreviewHandler(w http.ResponseWriter, r *http.Request) {
    var req PreviewRequest
    if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
        writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid request body"})
        return
    }

    html, err := engine.RenderMarkdown(req.Content)
    if err != nil {
        writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "render failed: " + err.Error()})
        return
    }

    writeJSON(w, http.StatusOK, map[string]string{"html": html})
}

The handler is registered in cmd/server.go with auth middleware:

apiMux.Handle("POST /api/preview", auth.AuthMiddleware(authCfg.store, authCfg.jwtSecret)(http.HandlerFunc(handlers.PreviewHandler)))

stripFrontmatter

A critical addition: RenderMarkdown now strips YAML frontmatter before converting to HTML. Without this, the preview would render --- delimiters and YAML keys as Markdown content:

func RenderMarkdown(content string) (string, error) {
    content = stripFrontmatter(content)
    var buf bytes.Buffer
    if err := renderer.Convert([]byte(content), &buf); err != nil {
        return "", err
    }
    return buf.String(), nil
}

The stripFrontmatter function (and its sibling extractFrontmatterBody) were moved out of ExtractFrontmatter into their own reusable functions, so they can be called both by the build renderer and the preview handler.

Scoped Document Events

Phase 4 refactored event dispatching in <bliki-editable> and <bliki-editor> from custom DOM events with bubbles: true to scoped document events. Instead of new CustomEvent('edit-start', { bubbles: true }), events are now dispatched as:

document.dispatchEvent(new CustomEvent('bliki-editable:edit-start', {
    detail: { content: this._content }
}));

Each component registers handlers on the document with a scope prefix that matches its component name. The handleEvent method filters events by scope:

handleEvent(event) {
    const eventScope = event.type.split(':')[0];
    const componentScope = 'bliki-preview';
    if (eventScope !== componentScope) return;

    const eventType = event.type.split(':')[1];
    const methodName = 'on' + eventType
        .split('-')
        .map(word => word.charAt(0).toUpperCase() + word.slice(1))
        .join('');
    this[methodName](event);
}

This eliminates event bubbling confusion — each component only responds to its own scoped events. <bliki-toolbar> dispatches bliki-preview:editor-input to update the preview. <bliki-editor> dispatches bliki-preview:edit-start when the user clicks Edit.

Template Overhaul

The editable.tmpl partial was redesigned with a grid layout — toolbar at the top, textarea and preview side-by-side below:

<bliki-editor hidden>
  <bliki-toolbar>
    <div role="group">
      <!-- formatting buttons -->
    </div>
  </bliki-toolbar>
  <div class="grid">
    <textarea rows="20"></textarea>
    <bliki-preview hidden>
      <div preview-content></div>
    </bliki-preview>
  </div>
  <button save>Save</button>
  <button cancel>Cancel</button>
</bliki-editor>

The post.tmpl layout was created to wrap blog posts with the editable decorator — it wasn't present before:

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="utf-8">
    <title>{{ .Current.Title }}</title>
    {{ template "partials/styles.tmpl" . }}
  </head>
  <body>
    {{ template "partials/header.tmpl" . }}
    <main class="container">
      {{ if .Current.TOC }}
      <article>
        <details name="toc" open>
          <summary>Table of Contents</summary>
          {{ content .Current.TOC }}
        </details>
      </article>
      {{ end }}
      <h5><time class="secondary">{{ date .Current.Date . }}</time></h5>
      {{ template "partials/editable.tmpl" . }}
    </main>
    {{ template "partials/footer.tmpl" . }}
    {{ template "partials/webcomponents.tmpl" . }}
  </body>
</html>

The login.tmpl layout was also added for the /login page, and a login.md content file was scaffolded as part of new site.

Web Components Registration

webcomponents.tmpl was updated to load the new scripts:

<script defer src="/js/bliki-logged-user.js"></script>
<script defer src="/js/bliki-login.js"></script>
<script defer src="/js/bliki-content.js"></script>
<script defer src="/js/bliki-editor.js"></script>
<script defer src="/js/bliki-editable.js"></script>
<script defer src="/js/bliki-toolbar.js"></script>
<script defer src="/js/bliki-preview.js"></script>

What Was Removed

The Playwright e2e test suite (tests/playwright/) was removed in the same commit. The fixture-based e2e tests that replaced it use the same Playwright engine but are structured differently — test fixtures live in tests/e2e/fixtures/ with their own content/, theme/, and bliki.toml directories. This is a separate topic covered in the next post.

What's Next

Phase 4 delivered a WYSIWYG editing experience with live preview, a formatting toolbar, and scoped event communication. Phase 4.1 focused on consolidation and code quality: removing the project root theme/ directory (making embed/ the single source of truth), refactoring component properties, replacing the setState switch with a prototype map, and adding JSDoc documentation to all web component methods.