TALL Stack: GitDown on it!

Date Created: 26-Mar-2022
(Last Updated: 16-Jul-2022)

De-Lite Records / PolyGram Records, Public domain, via Wikimedia Commons

Background

When I made the, what some might think is odd, decision to include articles on my site about how it works, it was then inevitable that I would need to show blocks of code. The next logical step was that I should edit such content using Markdown markup language, and, hopefully, manage to get code-syntax styling, too. Markdown is also much less verbose and easier to read than HTML.

So then came the question of "How?", which was answered very nicely by GitDown by Caleb Porzio (also the creator of Alpine JS and Livewire!). I won't go into any detail about how to install GitDown because that is all explained on the linked page. I will focus on how I am using it.

I have four places on my site where I use Markdown language to edit content: the descriptions for checklists and taxa, and the content and summary of articles like this one. For that reason, I decided I should have a dedicated GitDown editor Livewire component, that I can then reuse in all four places.

The Livewire Component

The git-down-editor.blade component

@once
    @push('child-styles')
        @gitdown
    @endpush
@endonce
<div>
    <div x-data="gitDownEditorApp" x-cloak>
        <div x-show="editing">
            <x-label for="editor" :value="$editorLabel" />
            <textarea id="editor" wire:model.debounce.500ms="editorContent" x-on:change="$wire.call('updateEditor');" class="w-full {{ $heightClass }} rounded">{{ old('editorContent') }}</textarea>
        </div>
        @unless (empty($editorContent))
        <div x-show="!editing">
            <x-label for="previewer" :value="$previewerLabel" />
            <div class="markdown-body border w-full {{ $heightClass }} rounded px-3 py-2 overflow-y-auto" x-ref="previewer">
                {!! $previewerContent !!}
            </div>
        </div>
        <div class="flex items-center justify-end mt-3">
            <x-button x-show="editing" type="button" x-on:click="$wire.call('updatePreview'); editing=false;">Preview</x-button>
            <x-button x-show="!editing" type="button" x-on:click="editing=true;">Edit</x-button>
        </div>
        @endunless
    </div>
</div>

@once
    @push('child-scripts')
        <script>
            document.addEventListener('alpine:init', () => {
                Alpine.data('gitDownEditorApp', () => ({
                    editorContent: @entangle('editorContent'),
                    previewerContent: @entangle('previewerContent'),
                    editing: true,
                }));
            });
        </script>
    @endpush
@endonce

Basically, there is a <textarea> for editing the content, a <div> for previewing the resulting HTML, and two buttons for toggling between the two, and toggling the buttons themselves.

The section at the top pushes the GitDown CSS styles into the <head> via an @stack in my app layout Blade.

@once
    @push('child-styles')
        @gitdown
    @endpush
@endonce

For anyone not familiar with Laravel Blade, the @stack directive in a page template, creates an area into which component can @push code. If is common to have a stack in the <head> for any styles and another stack just above the closing </body> tag for scripts. The @gitdown directive is part of the GitDown package and provides an extremely convenient way of adding all the styles that GitDown uses. The @once direct ensures that the GitDown styles are pushed only once, even if the component is loaded multiple times.

The section at the bottom pushes JavaScript for the Alpine component into the stack for scripts.

document.addEventListener('alpine:init', () => {
    Alpine.data('gitDownEditorApp', () => ({
        editorContent: @entangle('editorContent'),
        previewerContent: @entangle('previewerContent'),
        editing: true,
    }));
});

For anyone not familar with Livewire, the @entangle directive ensures that the values of Alpine JS variables and the PHP variables in the component class are synchronized. The editing: true just means we start in editing mode (i.e. not previewing mode).

The bit in the middle is where the action takes place.

<div x-show="editing">
    <x-label for="editor" :value="$editorLabel" />
    <textarea id="editor" wire:model.debounce.500ms="editorContent" x-on:change="$wire.call('updateEditor');" class="w-full {{ $heightClass }} rounded"></textarea>
</div>
<div x-show="!editing">
    <x-label for="previewer" :value="$previewerLabel" />
    <div class="markdown-body border w-full {{ $heightClass }} rounded px-3 py-2 overflow-y-auto" x-ref="previewer">
        {!! $previewerContent !!}
    </div>
</div>
<div class="flex items-center justify-end mt-3">
    <x-button x-show="editing" type="button" x-on:click="$wire.call('updatePreview'); editing=false;">Preview</x-button>
    <x-button x-show="!editing" type="button" x-on:click="editing=true;">Edit</x-button>
</div>

The toggle of the various elements is controlled using x-show and the value of editing. The first <div>, which contains the <textarea>, and the first button, "Preview", show when editing === true. The second <div>, which contains the HTML preview, and the second button, "Edit", show when editing === false.

The content of the <textarea> is bound to the value of the editorContent PHP variable in the Livewire component class via wire:model. The debounce.500ms part means that the synchronization occurs only twice per second, not every time I type a character. I could probably make that delay longer because I am not generating the HTML preview on the fly, only when the "Preview" button is clicked. Reasons for that include it is more efficient and I reduce the risk of hitting the rate limit on GitHub, which is only 60 parses per hour because I don't have a GitHub app token. Oh, I forgot to mention that GitDown is a wrapper for GitHub's API, so it calls the GitHub API every time we preview the content. The way I work, it makes very little difference. One of the great benefits of Markdown is that the unparsed markup is not very far removed from plain text, so is very easy to read, so previewing can be kept to a minimum.

Also on the <textarea> I have x-on:change="$wire.call('updateEditor');". The x-on:change part in Alpine JS syntax for creating an onclick event handler. The $wire.call('updateEditor') part allows us to directly invoke the updateEditor() method of the GitDownEditor PHP class. How Kool is that?! We'll come to the class very soon.

The preview <div> simply displays the HTML produced by the GitHub parser using {!! $previewerContent !!}. Note that the {!! ... !!} syntax allows HTML output to be rendered directly, rather than being treated as a string.

You might have noticed the {{ $heightClass }} on both the <textarea> and the preview <div>. This allows me to set a height class (and any other class for that matter) independently each time I use the GitDown Editor component. I use this to set a smaller height for the summary than for the main content. Similarly, you might also have noticed :value="$editorLabel" and :value="$previewerLabel". These allow me to have some control over the labels from outside the component. This is important when I am editing articles because this component is displayed twice, so I want different labels for each.

The role of the "Edit" button is simple to set editing to true to cause the elements to toggle. The "Preview" button causes toggling the other way by setting editing to false. It also calls the updatePreview() method of the GitDownEditor PHP class.

The GitDownEditor class

class GitDownEditor extends Component
{
    public string $editorContent = '';
    public string $previewerContent = '';
    public string $fieldName = '';
    public string $editorLabel = '';
    public string $previewerLabel = '';
    public string $heightClass = '';

    public function mount(string $editorContent, string $fieldName, string $heightClass='h-96')
    {
        $this->editorContent = $editorContent;
        $this->fieldName = $fieldName;
        $this->heightClass = $heightClass;
        $label = ucfirst($fieldName);
        $this->editorLabel = "{$label} Editor";
        $this->previewerLabel = "{$label} Preview";
    }

    public function render()
    {
        return view('livewire.git-down-editor');
    }

    public function updateEditor()
    {
        $this->emitUp('editorContentChanged', $this->fieldName, $this->editorContent);
    }

    public function updatePreview()
    {
        $this->previewerContent = GitDown::parseAndCache($this->editorContent);
        $this->emitUp('previewerContentChanged', $this->fieldName, $this->previewerContent);
    }
}

The first half-and-a-bit is mostly code for setting up the properties that we have already discussed. The key parts are the two methods updateEditor() and updatePreview(), which are called from the Livewire Blade component, as memntioned above. The updatePreview() method performs the main job of parsing the Markdown and caching the result (to minimize calls to the GitHub API). It then uses the Livewire emitUp() method to emit an event that can be acted upon in the parent Livewire form component. The fieldName property is passed in by the parent component and passed back in the event, so the calling component knows which field has changed. This helps multiple GitDown Editor components to exist on the same page, without conflicting with each other (more on that below). The updateEditor() method simply emits an event. Incidentally, I tried to do this directly from the blade component using $wire.emitUp() but it couldn't cope with the line breaks in the editorContent content.

Using the component

As I am writing an article, I will use the my article Livewire form as an example. There are only two relevant lines of code in my article-form.blade

<livewire:git-down-editor wire:key="content" :editor-content="$content" field-name="content" />
<livewire:git-down-editor wire:key="summary" :editor-content="$summary" field-name="summary" height-class="h-40" />

Very simple! I have already explained the fieldName property (for anyone who doesn't know camel-case property names are used in the PHP and snake-case names are used as the attributes that get passed to those properties). The editorContent property is pretty obvious. The wire:key is essential for having to instances of a Livewire component on the same page, and must be unique across all instances.

The relevant code in the ArticleForm class:

class ArticleForm extends Component
{
...
    protected $listeners = ['editorContentChanged', 'previewerContentChanged'];
...
    public function editorContentChanged(string $fieldName, string $value)
    {
        if ( 'summary' === $fieldName ) {
            $this->summary = $value;
        } elseif ( 'content' === $fieldName ) {
            $this->content = $value;
        }
    }

    public function previewerContentChanged(string $fieldName, string $value)
    {
        if ( 'summary' === $fieldName ) {
            $this->summaryHtml = $value;
        } elseif ( 'content' === $fieldName ) {
            $this->contentHtml = $value;
        }
    }
}

Very simple, again! All we do here is name the Livewire events we are listening for, and defined the methods that deal with them. All the methods do is take the fieldName and value parameters passed via the event and use them to update the appropriate property of the ArticleForm object.