Overview
HydePHP is an amazing Static Site Generator built on top of Laravel Zero. This site is built using HydePHP.
Out of the box, Hyde supports assigning a single category to each blog post. I wanted to assign multiple categories to each blog post and allow users to filter posts by them. In this article, I'm calling them "tags".
In this article, we explore an approach to assigning one or more tags to blog posts in Hyde, complete with:
- a label and color assigned to each tag
- the ability for users to filter a list of posts by one or more tag
- persisting the selected list of tags in the URL (as query parameters)
- support for users clicking on a tag from a blog post's page to be taken to a list of matching posts
Use case
Assumptions
You have a working HydePHP 2 project with:
- NPM and package dependencies installed
- Blade views published
Tag enum
To start, let's create a string-backed PHP enum for each tag. Create a new file at app/Enums/Tag.php with the contents
below.
1<?php 2 3namespace App\Enums; 4 5enum Tag: string 6{ 7 case LARAVEL = 'laravel'; 8 case LIVEWIRE = 'livewire'; 9 case FILAMENT = 'filament';10 case ALPINE = 'alpine';11 case HYDE = 'hyde';12 case AWS = 'aws';13 case GITHUB = 'github';14 15 public static function values(): array16 {17 return collect(self::cases())->map(fn (self $tag) => $tag->value)->all();18 }19}
In the above file, we define each enum case we want to support. We also declare a static function, values, that returns
an array of strings containing all possible enum values.
Assigning a label
To assign a label to each enum case, create a public function on the Tag enum named getLabel. This method will not
accept any arguments and return a string representing the current enum case's label.
1<?php 2 3namespace App\Enums; 4 5enum Tag: string 6{ 7 // ... cases and other functions here 8 9 public function getLabel(): string 10 {11 return match ($this) {12 self::LARAVEL => 'Laravel',13 self::LIVEWIRE => 'Livewire',14 self::FILAMENT => 'Filament',15 self::ALPINE => 'Alpine',16 self::HYDE => 'Hyde',17 self::AWS => 'AWS',18 self::GITHUB => 'GitHub',19 };20 }21}
Assigning a color
In the same fashion as before, create a new public function on the Tag enum named getColor. Again, this function will
not accept any arguments and return a string. This time the returned string will be the lowercase name of a
Tailwind 4 color.
1<?php 2 3namespace App\Enums; 4 5enum Tag: string 6{ 7 // ... cases and other functions here 8 9 public function getColor(): string 10 {11 // must include new colors as source in: resources/assets/app.css12 return match ($this) {13 self::LARAVEL => 'red',14 self::LIVEWIRE => 'blue',15 self::FILAMENT => 'amber',16 self::ALPINE => 'green',17 self::HYDE => 'purple',18 self::AWS => 'yellow',19 self::GITHUB => 'gray',20 };21 }22}
Updating CSS
By default, Vite's build process will intentionally only include Tailwind CSS class names that are used within your project. This is done to minimize the size of the resulting CSS file, helping your site to load more quickly.
However, we'll be using some CSS class names dynamically when displaying our tags; the CSS classes used in some places will be determined at the time of the static site being generated.
To always include specific Tailwind CSS classes, regardless of if they are found present in any of the project's source files, we can safelist the class names.
Add the following CSS near the top of your resources/assets/app.css file; after the Tailwind configuration.
1@config '../../tailwind.config.js';2 3@source inline('bg-{red,green,blue,amber,purple,yellow,gray}-{50,100}'); 4@source inline('border-{red,green,blue,amber,purple,yellow,gray}-700');5@source inline('text-{red,green,blue,amber,purple,yellow,gray}-700');
Blade components
Badge component
Next let's create a simple Blade component with the file badge.blade.php. The component will accept a color property and a size property. The size property
will have a default size of 'md' (medium).
1@props(['color', 'size' => 'md']) 2@php 3 $badgeClasses = "badge badge-$color"; 4 $textSize = $size === 'md' ? 'base' : $size; 5 $textClasses = "text-$textSize text-$color-700"; 6 $paddingClasses = $size === 'md' ? 'px-2 py-0.5' : 'px-4 py-1.5'; 7 $backgroundClasses = "bg-$color-50"; 8 $borderClasses = "rounded-full border border-$color-700"; 9@endphp10<div class="{{ "$badgeClasses $textClasses $paddingClasses $backgroundClasses $borderClasses" }}">11 {{ $slot }}12</div>
This component consists of a div (containing all our CSS classes) and a rendering of the $slot variable.
Learn more about props here and slots here.
Tags list component
Let's also create Blade component responsible for displaying a list of tags underneath a post title. This component will be used when listing posts as well as when displaying an individual post.
The component will accept two properties: tags (an array of strings) and asLink (a boolean denoting if each tag
should be rendered as an anchor element). We'll set false as the default for asLink.
Note that this blade template uses the badge component we created in the step above.
1@props(['tags', 'asLink' => false]) 2<div class="flex flex-wrap gap-2 mb-2"> 3 @foreach(collect($tags)->map(fn (string $t) => \App\Enums\Tag::from($t)) as $tag) 4 @if($asLink) 5 <a href="/?tag={{ $tag->value }}" class="badge-link"> 6 @endif 7 <x-elements.badge :color="$tag->getColor()"> 8 {{ $tag->getLabel() }} 9 </x-elements.badge>10 @if($asLink)11 </a>12 @endif13 @endforeach14</div>
The root element in the component is a div with CSS classes for display (as flexbox), wrapping flex items if the content
width is too wide, a gap between each flex item, and a bottom margin for the element.
Within the root div element, we loop through each tag (turned into a Collection and mapped to instances of our Tag
enum) and render our badge component. The color property on the badge component is set to the tag's color, and the slot
passed to the badge component is the tag's label.
If the asLink property evaluates to true, an a (anchor) element wraps the badge component. This optional anchor
element uses Alpine.js to set the window.location.href upon the element being clicked. The element also contains CSS
classes to show a pointer icon as the cursor as well as the badge-link class, which we'll define next.
CSS for the Blade components
To make our site more interactive for the user, we'll use some custom CSS in resources/assets/app.css.
1/* ... other css */2 3.badge-link:hover .badge {4 @apply bg-white;5}6.badge-link:hover .badge,7.badge-link.badge-link-active .badge {8 @apply outline outline-current;9}
The above CSS will:
- apply
whiteas the background color of a badge when hovering over a badge contained by a link - apply an outline of the current color when hovering over a badge contained by a link or when a badge link is denoted as "active"
Alpine data function
We need some JavaScript logic to facilitate the interactivity of our menu of tags the user can select from.
We'll define an Alpine.js data function in our resources/assets/app.js file.
This data function will return the definition of an Alpine.js data object which will be bound to x-data on our main
content element.
Add the following to your app.js file:
1document.addEventListener('alpine:init', () => { 2 Alpine.data('tagsMenu', (options) => ({ 3 selectedTags: [], 4 init() { 5 this.selectedTags = (new URLSearchParams(window.location.search)) 6 .getAll('tag') 7 .filter((tag) => options.tags.includes(tag)); 8 }, 9 toggleTag(tag) {10 this.selectedTags = this.selectedTags.includes(tag)11 ? this.selectedTags.filter((t) => t !== tag)12 : [...this.selectedTags, tag];13 14 const query = !!this.selectedTags.length15 ? new URLSearchParams(this.selectedTags.map((t) => ['tag', t]))16 : null;17 18 window.history.replaceState(null, null, query ? `/?${query.toString()}` : '/');19 },20 }))21});
In the above JavaScript, we add an event listener on the global document object that listens for the
alpine:init lifecycle hook. When the hook fires,
we'll register Alpine data under the name tagsMenu. In the callback (second argument), we accept an options
argument that will be used when resolving the data object.
As for the data object itself:
- the
selectedTagsproperty will contain an array of strings representing which tags are currently selected - the
initfunction is called by Alpine when the component is first initialized- we're using the function to set the
selectedTagsproperty based on the URL's query parameters
- we're using the function to set the
- the
toggleTagfunction accepts atagargument and is responsible for selecting or deselecting a specific tag- the function also uses the
History APIto replace the current state with query parameters for the currently selected tags
- the function also uses the
Tag selection menu
With our Alpine.js data in place, we can now use it within a Blade template.
index (home) page, located at _pages/index.blade.php.
Alpine.js component
In _pages/index.blade.php (or wherever you are listing posts), add an x-data attribute to the main element
(or a div element containing your tag menu and list of posts).
1<main id="content" class="mx-auto max-w-7xl py-12 px-8"2 x-data="tagsMenu({3 tags: @js(\App\Enums\Tag::values())4 })"5>6 <!-- ... other HTML -->7</main>
Note that we're using the @js Blade directive to render the array of possible tag values as JSON. We use this JSON
array as the value of the tags property in the object we're passing to the callback argument to the tagsMenu function.
This tells our Alpine.js component logic which tags are valid for selection.
Rendering menu items
Within the main (or div) element that will contain your tags menu and list of posts, you can render the tags menu as
shown below.
1<main id="content" class="mx-auto max-w-7xl py-12 px-8" 2 x-data="tagsMenu({ 3 tags: @js(\App\Enums\Tag::values()) 4 })" 5> 6 <!-- ... other HTML --> 7 8 <div class="mt-4 flex flex-wrap gap-2 md:justify-center"> 9 @foreach(\App\Enums\Tag::cases() as $tag)10 <button @click="toggleTag('{{ $tag->value}}')"11 :class="{12 'badge-link rounded-full cursor-pointer': true,13 'badge-link-active': selectedTags.includes('{{ $tag->value }}')14 }">15 <x-elements.badge size="xl" :color="$tag->getColor()">16 {{ $tag->getLabel() }}17 </x-elements.badge>18 </button>19 @endforeach20 </div>21 22 <!-- ... other HTML -->23</main>
Here we use a flexbox, with wrapping of items and a gap between items, to contain a button element for each available
tag (as defined by the cases in our Tag enum).
Each button uses the click event handler to call the toggleTag
function with an argument of a string representing which tag has been clicked on. Each button also has a binding to the
class attribute (using class object syntax) to conditionally
include or exclude the badge-link-active CSS class.
Within the slot for each button, we render our badge Blade component with an xl (extra large) size and the tag's color
passed as the color property. The badge component's slot contains the label for the relevant tag.
Conditionally displaying posts
Find in your project where MarkdownPost::getLatestPosts() is called in a Blade template. By default, after
publishing views, this will be at
resources/views/vendor/hyde/components/blog-post-feed.blade.php.
Within the foreach loop that loops through posts, a li (list item) element is rendered. We need to add attributes to
this element to control if the list item is displayed or hidden.
1<ol itemscope itemtype="https://schema.org/ItemList">2 @foreach($posts ?? MarkdownPost::getLatestPosts() as $post)3 <li x-show="!selectedTags.length || @js($post->matter('tags', [])).some((tag) => selectedTags.includes(tag))" 4 itemprop="itemListElement" itemscope itemtype="https://schema.org/ListItem" class="mt-4 mb-8">5 <meta itemprop="position" content="{{ $loop->iteration }}">6 @include('hyde::components.article-excerpt')7 </li>8 @endforeach9</ol>
The x-show directive is being used to conditionally display the list item when
one of the following conditions are true:
- there are no tags selected
- the tags assigned to the post overlap the currently selected tags from the tags menu
We are retrieving the tags assigned to the post with the matter method on the MarkdownPost class (available via the
InteractsWithFrontMatter trait on a parent class). If no tags are assigned, the second argument to the matter method
will be returned (in this case, an empty array).
Listing tags underneath posts
To display tags underneath posts, we can use the components.post.tags Blade component we created in a previous step.
In a list of posts
Within a list of posts, I want tag badges to have no interaction (instead of loading a list of posts filtered by the
tag that was clicked on). I also only want to include the rendered components.post.tags Blade component when there is
at least one tag associated with the post.
To accomplish this, we can use the @includeWhen Blade directive. Within the @foreach(...) loop that renders your list
of posts, add the following snippet in the location you'd like the rendered list of tags to display. By default, this
is somewhere within the Blade template: resources/views/vendor/hyde/components/article-excerpt.blade.php.
1<footer>2 <div class="mt-2">3 @includeWhen(!empty($post->matter('tags')), 'components.post.tags', ['tags' => $post->matter('tags')]) 4 </div>5 <a href="{{ $post->getRoute() }}" class="text-indigo-500 hover:underline font-medium">6 Read post7 </a>8</footer>
Note that we are omitting the asLink property, so it is defaulting to a value of false.
On the post's page
On the dedicated page for each post, I want tag badges to be interactive. When a user clicks on a tag, they should be taken to a page listing posts that have been assigned that tag.
We can again use the @includeWhen Blade directive to accomplish this. However, this time we will pass true as the
value of the asLink property.
This time we'll update the resources/views/vendor/hyde/components/post/article.blade.php file to change the header that
shows on the page dedicated to each post.
1<header aria-label="Header section" role="doc-pageheader"> 2 <h1 itemprop="headline" class="mb-4">{{ $page->title }}</h1> 3 <div id="byline" aria-label="About the post" role="doc-introduction"> 4 @includeWhen(!empty($page->matter('tags')), 'components.post.tags', [ 5 'tags' => $page->matter->get('tags'), 6 'asLink' => true, 7 ]) 8 @includeWhen(isset($page->date), 'hyde::components.post.date') 9 @includeWhen(isset($page->author), 'hyde::components.post.author')10 </div>11</header>
Serving and building your site
You can serve your site locally to preview your changes with the following command:
1php hyde serve --vite
To build your site, run the following commands:
1npm run build2php hyde build --vite
Conclusion
This article summarizes how this site, built using HydePHP and Alpine.js, achieves assigning one or more tags to each post. Posts can be filtered by selecting tags, which persists in the URL as query parameters.