Half-Elf on Tech

Thoughts From a Professional Lesbian

Year: 2025

  • Meilisearch at Home

    Meilisearch at Home

    There are things most CMS tools are great at, and then there are things they suck at. Universally? They all suck at search when you get to scale.

    This is not really true fault of the CMS (be it WordPress, Drupal, Hugo, etc). The problem is search is difficult to build! If it was easy, everyone would do it. The whole reason Google rose to dominance was that it made search easy and reliable. And that’s great, but not everyone is okay with relying on 3rd party services.

    I’ve used ElasticSearch (too clunky to run, a pain to customize), Lunr (decent for static sites), and even integrated Yahoo and Google searches. They all have issues.

    Recently I was building out a search tool for a private (read: internal, no access if you’re not ‘us’) service, and I was asked to do it with MeiliSearch. It was new to me. As I installed and configured it, I thought … “This could be a nice solution.”

    Build Your Instance

    When you read the directions, you’ll notice they want to install the app as root, meaning it would be one install. And that sounds okay until you start thinking about multiple servers using one instance (for example, WordPress Multisite) where you don’t want to cross contaminate your results. Wouldn’t want posts from Ipstenu.org and Woody.com showing up on HalfElf, and all.

    There are a couple of ways around that, Multi-Tenancy and multiple Indexes. I went with the indexes for now, but I’m sure I’ll want tenancy later.

    I’m doing all this on DreamHost, because I love those weirdos, but there are pre-built images on DigitalOcean if that floats your goat:

    1. Make a dedicated server or a DreamCompute (I used the latter) – you need root access
    2. Set the server to Nginx with the latest PHP – this will allow you to make a proxy later
    3. Add your ssh key from ~/.ssh/id_rsa.pub to your SSH keys – this will let you log in root (or an account with root access)

    Did that? Great! The actual installation is pretty easy, you can just follow the directions down the line.

    Integration with WordPress

    The first one I integrated with was WordPress and for that I used Yuto.

    It’s incredibly straightforward to set up. Get your URL and your Master Key. Plunk them in. Save. Congratulations!

    On the Indices page I originally set my UIDs to ipstenu_posts and ipstenu_pages – to prevent collisions. But then I realized… I wanted the whole site on there, so I made them both ipstenu_org

    Example of Yuto's search output
    Yuto Screenshot

    I would like to change the “Ipstenu_org” flag, like ‘If there’s only one Index, don’t show the name’ and then a way to customize it.

    I will note, there’s a ‘bug’ in Yuto – it has to load all your posts into a cache before it will index them, and that’s problematic if you have a massive amount of posts, or if you have anti-abuse tools that block long actions like that. I made a quick WP-CLI command.

    WP-CLI Command

    The command I made is incredibly simple: wp ipstenu yuto build-index posts

    The code is fast, too. It took under a minute for over 1000 posts.

    After I made it, I shared it with the creators of Yuto, and their next release includes a version of it.

    Multiple Indexes and Tenants

    You’ll notice that I have two indexes. This is due to how the plugin works, making an index per post type. In so far as my ipstenu.org sites go, I don’t mind having them all share a tenant. After all, they’re all on a server together.

    However… This server will also house a Hugo site and my other WP site. What to do?

    The first thing I did was I made a couple more API keys! They have read-write access to a specific index (the Key for “Ipstenu” has access to my ipstenu_org index and so on). That lets me manage things a lot more easily and securely.

    While Yuto will make the index, it cannot make custom keys, so I used the API:

    curl \
    -X POST 'https://example.com/keys' \
    -H 'Authorization: Bearer BEARERKEY' \
    -H 'Content-Type: application/json' \
    --data-binary '{
    "description": "Ipstenu.org",
    "actions": ["*"],
    "indexes": ["ipstenu_org"],
    "expiresAt": null
    }'
    

    That returns a JSON string with (among other things) a key that you can use in WordPress.

    Will I look into Tenancy? Maybe. Haven’t decided yet. For now, separate indexes works for me.

  • Cookie Consent on Hugo

    Cookie Consent on Hugo

    Hugo is my favourite static site generator. I use it on a site I originally created in 1996 (yes, FLF is about to be 30!). Over the last 6 months, I’ve been totally overhauling the site from top to bottom, and one of the long-term goals I had was to add in Cookie Consent.

    Hugo Has Privacy Mode

    One of the nice things about Hugo is they have a built in handler for Privacy Mode.

    I have everything set to respect Do Not Track and use PrivacyMode whenever possible. It lightens my load a lot.

    Built Into Hinode: CookieYes

    The site makes use of Hinode, which has built in support for cookie consent… Kind of. They use the CookieYes service, which I get but I hate. I don’t want to offload things to a service. In fact, part of the reason I moved of WordPress and onto Hugo for the site was GDPR.

    I care deeply about privacy. People have a right to privacy, and to opt in to tracking. A huge part of that is to minimize the amount of data from your own websites that are sent around to other people and saved on your own server/services!

    Obviously I need to know some things. I need to know how many mobile users there are so I can make it better. I need to know what pages have high traffic so I can expand them. If everyone is going to a recap page only to try and find a gallery, then I need to make those more prominent.

    In other words, I need Analytics.

    And the best analytics? Still Google.

    Sigh.

    Alternatively: CookieConsent

    I did my research. I checked a lot of services (free and pay), I looked into solutions people have implemented for Hugo, and then I thought there has to be a simple tool for this.

    There is.

    CookieConsent.

    CookieConsent is a free, open-source (MIT) mini-library, which allows you to manage scripts — and consequently cookies — in full GDPR fashion. It is written in vanilla js and can be integrated in any web platform/framework.

    And yes, you can integrate with Hugo.

    How to Add CookieConsent to Hugo

    First, download it. I have node set up to handle a lot of things, so I went with the easy route:

    npm i vanilla-cookieconsent@3.1.0

    Next, I have to add the dist files to my site. I added in a command to my package.json:

    "build:cookie": "cp node_modules/vanilla-cookieconsent/dist/cookieconsent.css static/css/cookieconsent.css && cp node_modules/vanilla-cookieconsent/dist/cookieconsent.umd.js static/js/cookieconsent.umd.js",
    

    If you’re familiar with Hinode, may notice I’m not using the suggested way to integrate JS. If I was doing this in pure Hinode, I’d be copying the files to assets/js/critical/functional/ instead of my static folder.

    I tried. It errors out:

    Error: error building site: EXECUTE-AS-TEMPLATE: failed to transform "/js/critical.bundle-functional.js" (text/javascript): failed to parse Resource "/js/critical.bundle-functional.js" as Template:: template: /js/critical.bundle-functional.js:210: function "revisionMessage" not defined
    

    I didn’t feel like debugging the whole mess.

    Anyway, once you get those files in, you need to make another special js file. This file is your configuration or initialization file. And if you look at the configuration directions, it’s a little lacking.

    Instead of that, go look at their Google Example! This gives you everything you need to comply with Google Tag Manager Consent Mode, which matters to me. I copied that into /static/js/cookieconsent-init.js and customized it. Like, I don’t have ads so I left that out.

    Add Your JS and CSS

    I already have a customized header (/layouts/partials/head/head.html) for unrelated reasons, but if you don’t, copy the one from Hinode core over and add in this above the call for the SEO file:

    <link rel="stylesheet" href="/css/cookieconsent.css">

    Then you’ll want to edit /layouts/partials/templates/script.html and add in this at the bottom:

    <script type="module" src="/js/cookieconsent-init.js"></script>

    Since your init file contains the call to the main consent code, you’re good to go!

    The Output

    When you visit the site, you’ll see this:

    Screenshot of a cookie consent page.
    Screenshot

    Now there’s a typo in this one, it should say “That means if you click “Reject” right now, you won’t get any Google Analytics cookies.” I fixed it before I pushed anything to production. But I made sure to specify that so people know right away.

    If you click on manage preferences, you’ll get the expanded version:

    Screenshot of expanded cookie preferences.
    Screenshot

    The language is dry as the desert because it’s to meet Google’s specifics.

    As for ‘strictly necessary cookies’?

    At this time we have NO necessary cookies. This option is here as a placeholder in case we have to add any later. We’ll notify you if that happens.

    And how will I notify them? By using Revision Management.

  • Replacing the W in Your Admin Bar

    Replacing the W in Your Admin Bar

    This is a part of ‘white labeling’, which basically means rebranding.

    When you have a website that is not used by people who really need to mess with WordPress, nor learn all about it (because you properly manage your own documentation for your writers), then that W in your admin toolbar is a bit odd, to say the least.

    This doesn’t mean I don’t want my editors to know what WordPress is, we have a whole about page, and the powered-by remains everywhere in the admin pages, but that logo…

    Well anyway, I decided to nuke it.

    Remove What You Don’t Need

    First I made a function that removes everything I don’t need:

    function cleanup_admin_bar(): void {
    	global $wp_admin_bar;
    
    	// Remove customizer link
    	$wp_admin_bar->remove_menu( 'customize' );
    
    	// Remove WP Menu things we don't need.
    	$wp_admin_bar->remove_menu( 'contribute' );
    	$wp_admin_bar->remove_menu( 'wporg' );
    	$wp_admin_bar->remove_menu( 'learn' );
    	$wp_admin_bar->remove_menu( 'support-forums' );
    	$wp_admin_bar->remove_menu( 'feedback' );
    
    	// Remove comments
    	$wp_admin_bar->remove_node( 'comments' );
    }
    
    add_action( 'wp_before_admin_bar_render','cleanup_admin_bar' );
    

    I also removed the comments node and the customizer because this site doesn’t use comments, and also how many times am I going to that Customizer anyway? Never. But the number of times I miss-click on my tablet? A lot.

    But you may notice I did not delete everything. That’s on purpose.

    Make Your New Nodes

    Instead of recreating everything, I reused some things!

    function filter_admin_bar( $wp_admin_bar ): void {
    	// Remove Howdy and Name, only use avatar.
    	$my_account = $wp_admin_bar->get_node( 'my-account' );
    
    	if ( isset( $my_account->title ) ) {
    		preg_match( '/<img.*?>/', $my_account->title, $matches );
    
    		$title = ( isset( $matches[0] ) ) ? $matches[0] : '<img src="fallback/images/avatar.png" alt="SITE NAME" class="avatar avatar-26 photo" height="26" width="26" />';
    
    		$wp_admin_bar->add_node(
    			array(
    				'id'    => 'my-account',
    				'title' => $title,
    			)
    		);
    	}
    
    	// Customize the Logo
    	$wp_logo = $wp_admin_bar->get_node( 'wp-logo' );
    	if ( isset( $wp_logo->title ) ) {
    		$logo = file_get_contents( '/images/site-logo.svg' );
    		$wp_admin_bar->add_node(
    			array(
    				'id'     => 'wp-logo',
    				'title'  => '<span class="my-site-icon" role="img">' . $logo . '</span>',
    				'parent' => null,
    				'href'   => '/wp-admin/admin.php?page=my-site',
    				'group'  => null,
    				'meta'   => array(
    					'menu_title' => 'About SITE',
    				),
    			),
    		);
    		$wp_admin_bar->add_node(
    			array(
    				'parent' => 'wp-logo',
    				'id'     => 'about',
    				'title'  => __( 'About SITE' ),
    				'href'   => '/about/',
    			)
    		);
    		$wp_admin_bar->add_node(
    			array(
    				'parent' => 'wp-logo-external',
    				'id'     => 'documentation',
    				'title'  => __( 'Documentation' ),
    				'href'   => 'https://docs.example.com/',
    			)
    		);
    		$wp_admin_bar->add_node(
    			array(
    				'parent' => 'wp-logo-external',
    				'id'     => 'slack',
    				'title'  => __( 'Slack' ),
    				'href'   => 'https://example.slack.com/',
    			)
    		);
    		$wp_admin_bar->add_node(
    			array(
    				'parent' => 'wp-logo-external',
    				'id'     => 'validation',
    				'title'  => __( 'Data Validation' ),
    				'href'   => '/wp-admin/admin.php?page=data_check',
    			)
    		);
    		$wp_admin_bar->add_node(
    			array(
    				'parent' => 'wp-logo-external',
    				'id'     => 'monitors',
    				'title'  => __( 'Monitors' ),
    				'href'   => '/wp-admin/admin.php?page=monitor_check',
    			)
    		);
    	}
    }
    
    add_filter( 'admin_bar_menu', array( $this, 'filter_admin_bar' ), PHP_INT_MAX );
    

    I replaced the default ‘about’ with my site’s about URL. I replaced the documentation node with my own. Everything else is new.

    Now the image… I have an SVG of our logo, and by making my span class named my-site-icon, I was able to spin up some simple CSS:

    #wpadminbar span.my-site-icon svg {
    	width: 25px;
    	height: 25px;
    }
    

    And there you are.

    Result

    What’s it look like?

    Screenshot of an alternative dropdown of what was the W logo into something customized.

    All those links are to our tools or documentation.