A rendered code example with Statamic, Tailwind and Alpine

For recent journal entries about SVG clipping masks and animating clipping masks I wanted to render actual code examples in the article to show the reader what the end result of the tutorial would look like. Since I use the Tailwind CSS JIT compiler my website ships with a CSS file that contains only the classes that are actually being used in my template files. That seems very restricting as I would probably always use some unavailable class in my examples. So I came up with a solution that utilizes the Tailwind Play CDN to live parse the code snippet and return the CSS needed. This is an example of what we're building.

I agree that my submitted data is being collected and stored. Learn more
An example of the component we're making.

Since the Tailwind CDN comes from a non EU server, personal information (IP-address and maybe more) will transfer outside of the EU. To respect the GDPR we need the users permission for this. On this website I've put this information in my privacy statement and decided to ask for permission on a per-session basis. Let's get started.

The Bard set

My Statamic Starter Kit Peak uses Bard as the editor for long form content like this very article. Within it I have various sets like a figure, a code block and a table. If you don't use Peak you have to do the following manually. To quickly scaffold a new set in Peak however, simply run php please peak:add-set in your terminal and type in a set_name. This does three things:

  1. It generates resources/fieldsets/set_name.yaml where we can define our fields for this set. By default it also contains a sizing utility so in the Control Panel we can size the block normal, LG or XL.

  2. It generates resources/views/components/_set_name.antlers.html as a template for this set. The set will respect the sizing set in the control panel.

  3. It updates resources/fieldsets/article.yaml to include the generated set.

After running this command the Article Page Builder Block will contain the set. I named it HTML.

A screenshot of the freshly generated empty Bard set in the Statamic Control Panel.
The article block includes our new set called HTML.

By default the generated fieldset looks like this.

1title: HTML
2fields:
3 -
4 handle: size
5 field: common.size
6 config:
7 instructions: 'The size in which the HTML should be displayed.'
The generated fieldset YAML file.

We need to add a code field to be able to input any code that we want rendered on the frontend. The fieldset now looks like this.

1title: HTML
2fields:
3 -
4 handle: size
5 field: common.size
6 config:
7 instructions: 'The size in which the HTML should be displayed.'
8 -
9 handle: html
10 field:
11 theme: material
12 mode: htmlmixed
13 mode_selectable: false
14 indent_type: spaces
15 indent_size: 2
16 key_map: default
17 line_numbers: true
18 line_wrapping: true
19 display: Code
20 type: code
21 icon: code
22 localizable: true
23 listable: hidden
24 instructions: 'Uses the Play CDN and JIT. Utilities will scoped under `tw-utility`.'
25 instructions_position: below
26 read_only: false
27 validate:
28 - required
The complete HTML fieldset YAML.

That fieldset will end up looking like this when you're working with the Bard field in the Statamic Control Panel.

A screenshot of how the HTML Bard set looks in the Statamic Control Panel.
This is how the fieldset looks in the Statamic Control Panel.

The template logic

To wire this all up we need to edit the generated template file. The following example is what Peak generates by default. It includes IDE hinting for the VS Code Antlers Toolbox, HTML comments to make it easier to debug your templates and the Peak sizing utilities that give the editor a choice how big the component should be rendered. Don't worry about this though. It's a specific thing to Peak and not important to what we're building in this article.

1{{#
2 @name HTML
3 @desc The HTML component.
4 @set page.article.html
5#}}
6 
7<!-- /components/_html.antlers.html -->
8<div
9 class="
10 not-prose
11 {{ switch(
12 (size == 'md') => 'size-md',
13 (size == 'lg') => 'size-lg',
14 (size == 'xl') => 'size-xl',
15 () => 'size-md'
16 )}}
17 "
18>
19 <h2>🔧<br>HTML</h2>
20</div>
21<!-- End: /components/_html.antlers.html -->
The bare bones HTML component template file.

The first thing we need to add is a bit Alpine logic. Since the user should give permission for using the Tailwind CDN I create a persisted store containing the permission. I'm using an Alpine store called twCDNConsent so a user only has to give permission once should a page contain multiple components. This is how it looks.

1{{ once }}
2 <script>
3 document.addEventListener('alpine:init', () => {
4 Alpine.store('twCDNConsent', {
5 value: Alpine.$persist(false).as('twCDNConsent').using(sessionStorage),
6 set(value) {
7 this.value = value
8 }
9 })
10 })
11 </script>
12{{ /once }}
The final template to render a code example.

Using a store makes all components reactive. This means a user only has to accept once per session and then all components will render. I use Alpine Persist to store this consent in the browsers sessionStorage. This storage value is also called twCDNConsent. The store gets a set() method to update the store value. If you omit .using(sessionStorage) Alpine will save it in the localStorage and the preference would persist across browser sessions. You might want to provide a way for users to reset their settings if you choose to got that route.

This store logic is wrapped in an Antlers tag as it only has to be defined once should your page contain multiple of those HTML components.

Now let's setup the HTML for this component. I'm keeping the styling in this example bare bones so it's easier to grasp.

1<div
2 x-data="html('{{ html | add_slashes | entities | collapse_whitespace }}')"
3 x-bind="root"
4 x-cloak
5>
6 <div x-bind="result">
7 </div>
8 
9 <div x-bind="consent" class="w-full h-44 p-6 flex flex-col justify-center items-center space-y-4">
10 <button x-bind="button" class="uppercase tracking-wider">
11 Render code
12 </button>
13 
14 <span>
15 I agree that my data is being collected and stored.
16 </span>
17 </div>
18</div>
The basic HTML structure of the component.

As you can see I extract the Alpine logic out of the component to get some syntax highlighting. For our x-data we call the html() function and pass in the escaped HTML from the code field for this very set. This way we only have to define our Javascript once and still use multiple of the same components on one page by passing in the unique HTML for each set.

In addition to that I've also setup four elements we can bind to:

  1. root: the root element.

  2. result: the div that should get the actual rendered HTML and should be hidden when no consent is given.

  3. consent: the consent wrapper that should be hidden when consent is given.

  4. button: the actual button a user has to click to give consent.

Let's setup the the Alpine logic now.

1document.addEventListener('alpine:initializing', () => {
2 Alpine.data('html', (html) => {
3 return {
4 html: '' ,
5 root: {
6 ['x-effect']() {
7 this.$store.twCDNConsent.value
8 && (this.html = html)
9 && this.loadTailwind()
10 }
11 },
12 consent: {
13 ['x-show']() {
14 return !this.$store.twCDNConsent.value
15 }
16 },
17 button: {
18 ['@click']() {
19 this.$store.twCDNConsent.set(true)
20 }
21 },
22 result: {
23 ['x-html']() {
24 return this.html
25 }
26 },
27 loadTailwind() {
28 let tailwind = document.createElement('script')
29 tailwind.src = 'https://cdn.tailwindcss.com'
30 this.$root.appendChild(tailwind)
31 let config = document.createElement('script')
32 config.textContent = 'tailwind.config={prefix:\'tw-\',corePlugins:{preflight:false}}'
33 tailwind.onload = () => { this.$root.appendChild(config) }
34 },
35 }
36 })
37})
The actual Alpine logic.

Lets go through this code step by step:

  1. First we set an html property that will later contain the HTML that you entered in the code field in your fieldset.

  2. We're binding the x-effect property to the root element. In this we use short circuit operators that do the following. If consent has been given, set the html property with the html that we passed in this function. If that is successful run the loadTailwind() function.

  3. Our consent element should only show when consent has not yet been given. We reach into our previously created $store.twCDNConsent and get its value with .value.

  4. When the button is clicked consent should be set to true. For this we use the set() method we added to the store.

  5. The result part should add the html property as its inner HTML. Since the HTML on this site always comes from me I trust the contents.

  6. Inside the function we create a script tag and set the source to the Tailwind CDN. This tag can be appended to the root element.

  7. We then create another script tag containing the configuration for the Tailwind CDN. We instruct the CDN to prefix all of our utilities with tw- so that it doesn't return any CSS from the rest of the site. We also set preflight: false. This makes sure the returned CSS doesn't contain any reset CSS that could conflict with the styles for the rest of the site. It could overwrite your default font family for example. This config will be appended as soon as the Tailwind CDN script is loaded.

The final component should look something like this:

1{{#
2 @name HTML
3 @desc The HTML component.
4 @set page.article.html
5#}}
6 
7<!-- /components/_html.antlers.html -->
8<div
9 class="
10 not-prose
11 my-4
12 {{ switch(
13 (size == 'md') => 'size-md',
14 (size == 'lg') => 'size-lg',
15 (size == 'xl') => 'size-xl',
16 () => 'size-md'
17 )}}
18 "
19>
20 <div
21 x-data="html('{{ html | add_slashes | entities | collapse_whitespace }}')"
22 x-bind="root"
23 x-cloak
24 >
25 <div x-bind="result">
26 </div>
27 
28 <div x-bind="consent" class="w-full h-44 p-6 flex flex-col justify-center items-center space-y-4">
29 <button x-bind="button" class="uppercase tracking-wider">
30 Render code
31 </button>
32 
33 <span>
34 I agree that my data is being collected and stored.
35 </span>
36 </div>
37 </div>
38 
39 {{ once }}
40 <script>
41 document.addEventListener('alpine:init', () => {
42 Alpine.store('twCDNConsent', {
43 value: Alpine.$persist(false).as('twCDNConsent').using(sessionStorage),
44 set(value) {
45 this.value = value
46 }
47 })
48 })
49 
50 document.addEventListener('alpine:initializing', () => {
51 Alpine.data('html', (html) => {
52 return {
53 html: '' ,
54 root: {
55 ['x-effect']() {
56 this.$store.twCDNConsent.value
57 && (this.html = html)
58 && this.loadTailwind()
59 }
60 },
61 consent: {
62 ['x-show']() {
63 return !this.$store.twCDNConsent.value
64 }
65 },
66 button: {
67 ['@click']() {
68 this.$store.twCDNConsent.set(true)
69 }
70 },
71 result: {
72 ['x-html']() {
73 return this.html
74 }
75 },
76 loadTailwind() {
77 let tailwind = document.createElement('script')
78 tailwind.src = 'https://cdn.tailwindcss.com'
79 this.$root.appendChild(tailwind)
80 let config = document.createElement('script')
81 config.textContent = 'tailwind.config={prefix:\'tw-\',corePlugins:{preflight:false}}'
82 tailwind.onload = () => { this.$root.appendChild(config) }
83 }
84 }
85 })
86 })
87 </script>
88 {{ /once }}
89</div>
90<!-- End: /components/_html.antlers.html -->
The final component.

And that will give us a component we can use in our tutorials. Add some code and have it rendered on the frontend using the Tailwind CDN. In order for the styles to load the user has to give consent and the Tailwind CDN will only return styles actually used so the request stays as small as possible.

Did you like this article? Let me know on Twitter.

Localize alt texts in Statamic

  • Statamic
  • Accessibility
  • Antlers