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.
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 Markdown editor Livewire component, that I can then reuse in all four places.
I was originally using the GitDown package but that has been discontinued. I decided not to change the title of this article.
The Livewire Component
The markdown-editor.blade
component
@pushOnce('child-styles')
@vite("resources/css/github-markdown.css")
@endPushOnce
<div>
<div x-data="{
previewerContent: $wire.entangle('previewerContent').live,
editing: true,
}"
x-cloak
>
<div x-show="editing">
<x-label for="editor_{{ $fieldName }}" :value="$editorLabel" />
<textarea
id="editor_{{ $fieldName }}"
wire:model.live.debounce.500ms="editorContent"
wire:change="updateEditor;"
class="w-full {{ $heightClass }} rounded"
>
{{ old('editorContent') }}
</textarea>
</div>
@unless (empty($editorContent))
<div x-show="!editing">
<x-label for="previewer_{{ $fieldName }}" :value="$previewerLabel" />
<div class="markdown-body border w-full {{ $heightClass }} rounded px-3 py-2 overflow-y-auto">
{!! $previewerContent !!}
</div>
</div>
<div class="flex items-center justify-end mt-3">
<x-button x-show="editing" type="button" wire:click="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>
@pushOnce
is used to push the github-markdown.css
to my 'child-styles' stack in app-layout
. This handles basic styling of the HTML generated from the Markdown.
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.
For anyone not familiar with Livewire, $wire.entangle
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 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. 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 wire:change="updateEditor;"
. This allows us to directly invoke the updateEditor()
method of the MarkdownEditor
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 Markdown 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 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 simply 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 MarkdownEditor
PHP class.
The MarkdownEditor
class
class MarkdownEditor 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.markdown-editor');
}
public function updateEditor()
{
$this->dispatch('editorContentChanged', $this->fieldName, $this->editorContent);
}
public function updatePreview()
{
$this->previewerContent = Str::markdown($this->editorContent);
$this->dispatch('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 mentioned above. The updatePreview()
method performs the main job of parsing the Markdown and converting it to HTML, using Str::markdown()
. It then uses the Livewire dispatch()
method to dispatch 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 Markdown Editor components to exist on the same page, without conflicting with each other (more on that below). The updateEditor()
method simply dispatches an event. Incidentally, I tried to do this directly from the blade component using $wire.dispatch()
but it couldn't cope with the line breaks in the editorContent
content.
Using the component
As I am writing an article right no, 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:markdown-editor wire:key="content" :editor-content="$content" field-name="content" />
<livewire:markdown-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 multiple 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
{
...
#[On('editorContentChanged')]
public function editorContentChanged(string $fieldName, string $value)
{
if ( 'summary' === $fieldName ) {
$this->summary = $value;
} elseif ( 'content' === $fieldName ) {
$this->content = $value;
}
}
#[On('previewerContentChanged')]
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.
Showing the results
The articles.show
Blade is as follows.
<x-app-layout title="{{ $article->category->title }}: {{ $article->title }}">
@pushOnce('child-styles')
@vite("resources/css/github-markdown.css")
@vite("node_modules/highlight.js/styles/github.css")
@endPushOnce
@if ($disallowView)
<div class="flex h-96 w-full items-center justify-center bg-white">
<div>Sorry, this article is not available at the moment. Please try again later.</div>
</div>
@else
<x-slot name="header">
<div class="flex justify-between">
<div>
<h1 class="font-semibold text-xl text-gray-800 leading-tight">{{ $article->category->title }}: {{ $article->title }}<h1>
<div class="flex justify-between pt-1 text-sm">
<div><strong>Date Created</strong>: {{ $articleCreated }}</div>
@if ($articleCreated !== $articleUpdated)
<div class="ml-3">(<strong>Last Updated</strong>: {{ $articleUpdated }})</div>
@endif
</div>
</div>
@can('update', $article)
<div class="flex space-x-1">
@unless($article->published)
<livewire:article-publish-form :article="$article"/>
@endunless
<a href="{{ route('articles.edit', [$article->id]) }}"><x-button type="button">{{ __("Edit Article") }}</x-button></a>
</div>
@endcan
</div>
</x-slot>
<div class="max-w-full md:max-w-5xl mx-auto py-4 sm:py-6 sm:px-6 lg:px-8">
<div class="markdown-body p-3 md:p-6 border rounded-md bg-white">
{!! $article->content_html !!}
</div>
</div>
...
<x-up-to-top />
@endif
</x-app-layout>
@pushOnce('child-scripts')
<script>
Array.from(document.links)
.filter(link => link.hostname != window.location.hostname)
.forEach(link => link.target = '_blank');
</script>
@endPushOnce
The key part, as far as this article is concerned is just this:
<div class="markdown-body p-3 md:p-6 border rounded-md bg-white">
{!! $article->content_html !!}
</div>
Most of the rest is for allowing editing and publishing - all new articles are created in an unpublished state and only logged in users (i.e me) can see them. This allows me to check them before publishing.
The short script at the end adds target='_blank'
to all links that are on different sites to the article itself, to force them to open in a new tab/window.
Syntax Highlighting
Laravel's Str::markdown()
converts the Markdown to HTML but it does nothing to deal with syntax highlighting. For that I am using highlight.js and highlightjs-blade to handle Blade syntax highlighting. Both are included in my app via my app.js
file.
import './bootstrap';
import hljs from 'highlight.js/lib/core';
import javascript from 'highlight.js/lib/languages/javascript';
import php from 'highlight.js/lib/languages/php';
import xml from 'highlight.js/lib/languages/xml';
hljs.registerLanguage('javascript', javascript);
hljs.registerLanguage('php', php);
hljs.registerLanguage('xml', xml);
import hljsBlade from 'highlightjs-blade';
hljs.registerLanguage('blade', hljsBlade);
hljs.highlightAll();
The hljs.highlightAll()
method modifies any HTML content between <pre><code>
tags to add span
tags with classes to enable the syntax highlighting. Those styles are defined in github.css, which is declared in vite.config.js
.
import { defineConfig } from 'vite';
import laravel from 'laravel-vite-plugin';
export default defineConfig({
plugins: [
laravel([
'resources/css/app.css',
'resources/css/github-markdown.css',
'node_modules/highlight.js/styles/github.css',
'resources/js/app.js',
]),
],
});
It is used in the articles.show
Blade via:
@vite("node_modules/highlight.js/styles/github.css")
Setup issue with highlight.js
It might help other to know about an issue I had when I first started using highlight.js. Originally, I was importing github.css
in my app.js
file, which is inline with examples on highlight.js websites. This worked perfectly in my development environment. However, once deployed to my live site, the Javascript file produce by Vite during npm run build
was rejected by the browser because it had a type of text/html
. A little digging suggested that browsers can assign this type to files with mixed content (e.g. Javascript and CSS in the same file). Defining github.css
as a plugin in vite.config.js
and using @vite
in the view solved the problem.