Half-Elf on Tech

Thoughts From a Professional Lesbian

Category: How To

  • Adding Sort Options to Facet

    Adding Sort Options to Facet

    As I implement more and more aspects of FacetWP, I find more and more ways to manipulate the searches. At first I only added in the features that let people easily search for multiple aspects at once. But I hadn’t yet added in any features to sorting.

    Sorting and Ordering

    The way Facets generally work is that you can easily organize all ‘types’ together, so if you wanted to search for everything that crossed four separate categories, it was very easy. In addition, you can extend it to search meta data as well.

    Sorting, on the other hand, is changing the order of the results. For example, if you wanted to search for everyone with terms A, B, and D, and post meta foo, but order them based on post meta bar, you can!

    A Practical Example

    I always do better with examples I can wrap my hands around.

    Take television shows. Take a list of 500 TV shows, and have them include the following taxonomies:

    • Genres (drama, sitcom, etc)
    • Airdates (Year to Year)
    • Tropes (common tropes)
    • Number of characters
    • Number of dead characters

    That’s enough for now.

    With that list, and a couple facets, you can concoct a smaller list of all sitcoms that aired in between 2014 and 2016 (inclusive), with a trope of ‘sex workers.’ The answer is 4 by the way. By default, the list displays alphabetically.

    But. What if you wanted to order them by the ones with the most characters first?

    That’s sorting.

    The Code

    Okay so how do we add this in? Functions!

    Facet comes with quite a few defaults, but it lets you add your own sort options. The two things I’m going to show below are how to rename the display labels for some of the defaults, and how to add in one new option for the most number of characters:

    add_filter( 'facetwp_sort_options', 'DOMAIN_facetwp_sort_options', 10, 2 );
    
    function facetwp_sort_options( $options, $params ) {
    
    	$options['default']['label']    = 'Default (Alphabetical)';
    	$options['title_asc']['label']  = 'Name (A-Z)';
    	$options['title_desc']['label'] = 'Name (Z-A)';
    
    	if ( is_post_type_archive( 'DOMAIN_shows' ) ) {
    
    		$options['most_characters'] = array(
    			'label' => 'Number of Characters (Descending)',
    			'query_args' => array(
    				'orderby'  => 'meta_value_num', // sort by numerical
    				'meta_key' => 'DOMAIN_char_count',
    				'order'    => 'DESC', // descending order
    			)
    		);
    	}
    

    I have this wrapped in a check for is_post_type_archive because I don’t want the options to show on other pages. The meta key is the name of the meta key you’re going to use to sort by (I have key that updates every time a post is saved with a count of characters attached) and the orderby value is one of the ones WP Query can use.

    End result?

    A dropdown with the options

    Looks nice!

  • Taxonomy Icons

    Taxonomy Icons

    Last year I talked about how I made icons for my taxonomy terms. When you have a limited number of terms, that makes sense. When you have a taxonomy with the potential for a high volume of terms, like nations (192), or worse an unlimited number of terms, this approach looses its value.

    Instead, I realized what I needed for a particular project was a custom icon for each taxonomy. Not the term.

    I split this up into two files because I used a slightly different setup for my settings API, but the tl;dr of all this is I made a settings page under themes called “Taxonomy Icons” which loads all the public, non-default taxonomies and associates them with an icon.

    For this to work for you, you will need to have your images in a folder and define that as your IMAGE_PATH in the code below. Also mine is using .svg files, so change that if you’re not.

    File 1: taxonomy-icons.php

    The one gotcha with this is I usually set my default values with $this->plugin_vars = array(); in the __construct function. You can’t do that with custom taxonomies, as they don’t exist yet.

    class TaxonomyIcons {
    
    	private $settings;
    	const SETTINGS_KEY = 'taxicons_settings';
    	const IMAGE_PATH   = '/path/to/your/images/';
    
    	/*
    	 * Construct
    	 *
    	 * Actions to happen immediately
    	 */
        public function __construct() {
    
    		add_action( 'admin_menu', array( $this, 'admin_menu' ) );
    		add_action( 'admin_init', array( $this, 'admin_init' ) );
    		add_action( 'init', array( $this, 'init' ) );
    
    		// Normally this array is all the default values
    		// Since we can't set it here, it's a placeholder
    		$this->plugin_vars = array();
    
    		// Create the list of imagess
    		$this->images_array = array();
    		foreach ( glob( static::IMAGE_PATH . '*.svg' ) as $file) {
    			$this->images_array[ basename($file, '.svg') ] = basename($file);
    		}
    
    		// Permissions needed to use this plugin
    		$this->plugin_permission = 'edit_posts';
    
    		// Menus and their titles
    		$this->plugin_menus = array(
    			'taxicons'    => array(
    				'slug'         => 'taxicons',
    				'submenu'      => 'themes.php',
    				'display_name' => __ ( 'Taxonomy Icons', 'taxonomy-icons' ),
    			),
    		);
        }
    
    	/**
    	 * admin_init function.
    	 *
    	 * @access public
    	 * @return void
    	 * @since 0.1.0
    	 */
    	function admin_init() {
    		// Since we couldn't set it in _construct, we do it here
    		// Create a default (false) for all current taxonomies
    		$taxonomies = get_taxonomies( array( 'public' => true, '_builtin' => false ), 'names', 'and' );
    		if ( $taxonomies && empty( $this->plugin_vars ) ) {
    			foreach ( $taxonomies as $taxonomy ) {
    				$this->plugin_vars[$taxonomy] = false;
    			}
    		}
    	}
    
    	/**
    	 * init function.
    	 *
    	 * @access public
    	 * @return void
    	 * @since 0.1.0
    	 */
    	function init() {
    		add_shortcode( 'taxonomy-icon', array( $this, 'shortcode' ) );
    	}
    
    	/**
    	 * Get Settings
    	 *
    	 * @access public
    	 * @param bool $force (default: false)
    	 * @return settings array
    	 * @since 0.1.0
    	 */
    	function get_settings( $force = false) {
    		if ( is_null( $this->settings ) || $force ) {
    			$this->settings = get_option( static::SETTINGS_KEY, $this->plugin_vars );
    		}
    		return $this->settings;
    	}
    
    	/**
    	 * Get individual setting
    	 *
    	 * @access public
    	 * @param mixed $key
    	 * @return key value (if available)
    	 * @since 0.1.0
    	 */
    	function get_setting( $key ) {
    		$this->get_settings();
    		if ( isset( $this->settings[$key] ) ) {
    			return $this->settings[$key];
    		} else {
    			return false;
    		}
    	}
    
    	/**
    	 * Set setting from array
    	 *
    	 * @access public
    	 * @param mixed $key
    	 * @param mixed $value
    	 * @return void
    	 * @since 0.1.0
    	 */
    	function set_setting( $key, $value ) {
    		$this->settings[$key] = $value;
    	}
    
    	/**
    	 * Save individual setting
    	 *
    	 * @access public
    	 * @return void
    	 * @since 0.1.0
    	 */
    	function save_settings() {
    		update_option( static::SETTINGS_KEY, $this->settings );
    	}
    
    	/**
    	 * admin_menu function.
    	 *
    	 * @access public
    	 * @return void
    	 * @since 0.1.0
    	 */
    	function admin_menu() {
    
    		foreach ( $this->plugin_menus as $menu ) {
    			$hook_suffixes[ $menu['slug'] ] = add_submenu_page(
    				$menu['submenu'],
    				$menu['display_name'],
    				$menu['display_name'],
    				$this->plugin_permission,
    				$menu['slug'],
    				array( $this, 'render_page' )
    			);
    		}
    
    		foreach ( $hook_suffixes as $hook_suffix ) {
    			add_action( 'load-' . $hook_suffix , array( $this, 'plugin_load' ) );
    		}
    	}
    
    	/**
    	 * Plugin Load
    	 * Tells plugin to handle post requests
    	 *
    	 * @access public
    	 * @return void
    	 * @since 0.1.0
    	 */
    	function plugin_load() {
    		$this->handle_post_request();
    	}
    
    	/**
    	 * Handle Post Requests
    	 *
    	 * This saves our settings
    	 *
    	 * @access public
    	 * @return void
    	 * @since 0.1.0
    	 */
    	function handle_post_request() {
    		if ( empty( $_POST['action'] ) || 'save' != $_POST['action'] || !current_user_can( 'edit_posts' ) ) return;
    
    		if ( !wp_verify_nonce( $_POST['_wpnonce'], 'taxicons-save-settings' ) ) die( 'Cheating, eh?' );
    
    		$this->get_settings();
    
    		$post_vars = $this->plugin_vars;
    		foreach ( $post_vars as $var => $default ) {
    			if ( !isset( $_POST[$var] ) ) continue;
    			$this->set_setting( $var, sanitize_text_field( $_POST[$var] ) );
    		}
    
    		$this->save_settings();
    	}
    
    	/**
    	 * Render admin settings page
    	 * If setup is not complete, display setup instead.
    	 *
    	 * @access public
    	 * @return void
    	 * @since 0.1.0
    	 */
    	function render_page() {
    		// Not sure why we'd ever end up here, but just in case
    		if ( empty( $_GET['page'] ) ) wp_die( 'Error, page cannot render.' );
    
    		$screen = get_current_screen();
    		$view  = $screen->id;
    
    		$this->render_view( $view );
    	}
    
    	/**
    	 * Render page view
    	 *
    	 * @access public
    	 * @param mixed $view
    	 * @param array $args ( default: array() )
    	 * @return content based on the $view param
    	 * @since 0.1.0
    	 */
    	function render_view( $view, $args = array() ) {
    		extract( $args );
    		include 'view-' . $view . '.php';
    	}
    
    	/**
    	 * Render Taxicon
    	 *
    	 * This outputs the taxonomy icon associated with a specific taxonomy
    	 *
    	 * @access public
    	 * @param mixed $taxonomy
    	 * @return void
    	 */
    	public function render_taxicon ( $taxonomy ) {
    
    		// BAIL: If it's empty, or the taxonomy doesn't exist
    		if ( !$taxonomy || taxonomy_exists( $taxonomy ) == false ) return;
    
    		$filename = $this->get_setting( $taxonomy );
    
    		// BAIL: If the setting is false or otherwise empty
    		if ( $filename == false || !$filename || empty( $filename ) ) return;
    
    		$icon     = file_get_contents( static::IMAGE_PATH . $filename  . '.svg' );
    		$taxicon  = '<span role="img" class="taxonomy-icon ' . $filename . '">' . $icon . '</span>';
    
    		echo $taxicon;
    	}
    
    	/*
    	 * Shortcode
    	 *
    	 * Generate the Taxicon via shortcode
    	 *
    	 * @param array $atts Attributes for the shortcode
    	 *        - tax: The taxonomy
    	 * @return SVG icon of awesomeness
    	 */
    	function shortcode( $atts ) {
    		return $this->render_taxicon( $atts[ 'tax' ] );
    	}
    
    }
    
    new TaxonomyIcons();
    

    File 2: view-appearance_page_taxicons.php

    Why that name? If you look at my render_view function, I pass the ID from get_current_screen() to it, and that means the ID is appearance_page_taxicons and that’s the page name.

    <div class="wrap">
    
    	<h1><?php _e( 'Taxonomy Icons', 'taxonomy-icons' ); ?></h1>
    
    	<div>
    
    		<p><?php __( 'Taxonomy Icons allows you to assign an icon to a non-default taxonomy in order to make it look damn awesome.', 'taxonomy-icons' ); ?></p>
    
    		<p><?php __( 'By default, Taxonomy Icons don\'t display in your theme. In order to use them, you can use a shortcode or a function:' , 'taxonomy-icons' ); ?></p>
    
    		<ul>
    			<li>Shortcode: <code>[taxonomy-icon tax=TAXONOMY]</code></li>
    		</ul>
    
    		<form method="post">
    
    		<?php
    		if ( isset( $_GET['updated'] ) ) {
    			?>
    			<div class="notice notice-success is-dismissible"><p><?php _e( 'Settings saved.', 'taxonomy-icons' ); ?></p></div>
    			<?php
    		}
    		?>
    
    		<input type="hidden" name="action" value="save" />
    		<?php wp_nonce_field( 'taxicons-save-settings' ) ?>
    
    		<table class="form-table">
    
    			<tr>
    				<th scope="row"><?php _e( 'Category', 'taxonomy-icons' ); ?></th>
    				<th scope="row"><?php _e( 'Current Icon', 'taxonomy-icons' ); ?></th>
    				<th scope="row"><?php _e( 'Select Icon', 'taxonomy-icons' ); ?></th>
    			</tr>
    
    			<?php
    
    			foreach ( $this->plugin_vars as $taxonomy => $value ) {
    				?>
    				<tr>
    					<td>
    						<strong><?php echo get_taxonomy( $taxonomy )->label; ?></strong>
    						<br /><em><?php echo get_taxonomy( $taxonomy )->name; ?></em>
    					</td>
    
    					<td>
    						<?php
    						if ( $this->get_setting( $taxonomy ) && $this->get_setting( $taxonomy ) !== false ) {
    							echo $this->render_taxicon( $taxonomy );
    						}
    						?>
    
    					</td>
    
    					<td>
    						<select name="<?php echo $taxonomy; ?>" class="taxonomy-icon">
    							<option value="">-- <?php _e( 'Select an Icon', 'taxonomy-icons' ); ?> --</option>
    							<?php
    							foreach ( $this->images_array as $file => $name ) {
    								?><option value="<?php echo esc_attr( $file ); ?>" <?php echo $file == $this->get_setting( $taxonomy ) ? 'selected="selected"' : ''; ?>><?php echo esc_html( $name ); ?></option><?php
    								};
    							?>
    						</select>
    					</td>
    
    				</tr><?php
    			}
    
    			?>
    
    			<tr valign="top">
    				<td colspan="3">
    					<button type="submit" class="button button-primary"><?php _e( 'Save', 'taxonomy-icons' ); ?></button>
    				</td>
    			</tr>
    		</table>
    		</form>
    	</div>
    </div>
    

    End Result

    And in the end?

    An example of Taxonomy Icons

    By the way, the image has some wrong text, but that is what it looks like.

  • Grandchildren Templates

    Grandchildren Templates

    When I posted about my cleverness with grandchildren themes (which as Cosper pointed out was a plugin), reader Damien mentioned templates. He said:

    I have considered using template_redirect() to override template files in the child theme. I’ve experimented with providing selectable page templates where the template file is not in the child theme directory (not so easy).

    Well Damien, I think you’ll be pleased to know there is a solution.

    WPExplorer has made a WordPress page templates plugin. My caution is not to use the GitHub repo, which is not up to date, but copy the one in the post.

    However … I forked it. And my fork is only going to work if you have both WordPress 4.7 and PHP 7. That’s because in PHP 7, PHP finally decided to allow defines to have arrays.

    The Pre-Code

    I use the template in an MU plugin. It sits in a folder called “cpts” and is summoned by my master index.php file that has this:

    define( 'PAGE_TEMPLATER_ARRAY', [
    	'videos-template.php' => 'Videos Archive',
    ] );
    
    include_once( dirname( __FILE__ ) . '/cpts/NAME-OF-cpt.php' );
    include_once( dirname( __FILE__ ) . '/cpts/page-templater.php' );
    

    As you can see, I define my array and then I call the CPT file and the templater itself.

    The Template File

    This is 90% the same as the original. The two changes are I removed the check and failsafe for pre-WP 4.7, and I changed the section to add templates to call my define.

    <?php
    /*
    Plugin Name: Page Template Plugin
    Plugin URI: http://www.wpexplorer.com/wordpress-page-templates-plugin/
    Version: 2.0
    Author: WPExplorer
    */
    
    class PageTemplater {
    
    	/**
    	 * A reference to an instance of this class.
    	 */
    	private static $instance;
    
    	/**
    	 * The array of templates that this plugin tracks.
    	 */
    	protected $templates;
    
    	/**
    	 * Returns an instance of this class.
    	 */
    	public static function get_instance() {
    
    		if ( null == self::$instance ) {
    			self::$instance = new PageTemplater();
    		}
    
    		return self::$instance;
    
    	}
    
    	/**
    	 * Initializes the plugin by setting filters and administration functions.
    	 */
    	private function __construct() {
    
    		$this->templates = array();
    
    		// Add a filter to the wp 4.7 version attributes metabox
    		add_filter(
    			'theme_page_templates', array( $this, 'add_new_template' )
    		);
    
    		// Add a filter to the save post to inject out template into the page cache
    		add_filter(
    			'wp_insert_post_data',
    			array( $this, 'register_project_templates' )
    		);
    
    
    		// Add a filter to the template include to determine if the page has our
    		// template assigned and return it's path
    		add_filter(
    			'template_include',
    			array( $this, 'view_project_template')
    		);
    
    		$this->templates = PAGE_TEMPLATER_ARRAY;
    
    	}
    
    	/**
    	 * Adds our template to the page dropdown for v4.7+
    	 *
    	 */
    	public function add_new_template( $posts_templates ) {
    		$posts_templates = array_merge( $posts_templates, $this->templates );
    		return $posts_templates;
    	}
    
    	/**
    	 * Adds our template to the pages cache in order to trick WordPress
    	 * into thinking the template file exists where it doens't really exist.
    	 */
    	public function register_project_templates( $atts ) {
    
    		// Create the key used for the themes cache
    		$cache_key = 'page_templates-' . md5( get_theme_root() . '/' . get_stylesheet() );
    
    		// Retrieve the cache list.
    		// If it doesn't exist, or it's empty prepare an array
    		$templates = wp_get_theme()->get_page_templates();
    		if ( empty( $templates ) ) {
    			$templates = array();
    		}
    
    		// New cache, therefore remove the old one
    		wp_cache_delete( $cache_key , 'themes');
    
    		// Now add our template to the list of templates by merging our templates
    		// with the existing templates array from the cache.
    		$templates = array_merge( $templates, $this->templates );
    
    		// Add the modified cache to allow WordPress to pick it up for listing
    		// available templates
    		wp_cache_add( $cache_key, $templates, 'themes', 1800 );
    
    		return $atts;
    
    	}
    
    	/**
    	 * Checks if the template is assigned to the page
    	 */
    	public function view_project_template( $template ) {
    
    		// Get global post
    		global $post;
    
    		// Return template if post is empty
    		if ( ! $post ) {
    			return $template;
    		}
    
    		// Return default template if we don't have a custom one defined
    		if ( ! isset( $this->templates[get_post_meta(
    			$post->ID, '_wp_page_template', true
    		)] ) ) {
    			return $template;
    		}
    
    		$file = plugin_dir_path( __FILE__ ). get_post_meta(
    			$post->ID, '_wp_page_template', true
    		);
    
    		// Just to be safe, we check if the file exist first
    		if ( file_exists( $file ) ) {
    			return $file;
    		} else {
    			echo $file;
    		}
    
    		// Return template
    		return $template;
    
    	}
    
    }
    add_action( 'plugins_loaded', array( 'PageTemplater', 'get_instance' ) );
    

    So long as you have PHP 7+ and WP 4.7+, it all works great.

  • Hugo and Lunr – Client Side Searching

    Hugo and Lunr – Client Side Searching

    I use Hugo on a static website that has few updates but still needs a bit of maintenance. With a few thousand pages, it also needs a search. For a long time, I was using a Google Custom Search, but I’m not the biggest Google fan and they insert ads now, so I needed a new solution.

    Search is the Worst Part

    Search is the worst thing about static sites. Scratch that. Search is the worst part about any site. We all bag on WordPress’ search being terrible, but anyone who’s attempted to install and manage anything like ElasticSearch knows that WordPress’ search is actually pretty good. It’s just limited. And by contrast, the complicated world of search is, well, complicated.

    That’s the beauty of many CMS tools like WordPress and Drupal and MediaWiki is that they have a rudimentary and perfectly acceptable search built in. And it’s the headache of static tools like Jekyll and Hugo. They simply don’t have it.

    Lunr

    If you don’t want to use third-party services, and are interested in self hosting your solution, then you’re going to have to look at a JavaScript solution. Mine was Lunr.js, a fairly straightforward tool that searched a JSON file for the items.

    There are pros and cons to this. Having it all in javascript means the load on my server is pretty low. At the same time I have to generate the JSON file somehow every time. In addition, every time someone goes to the search page, they have to download that JSON file, which can get pretty big. Mine’s 3 megs for 2000 or so pages. That’s something I need to keep in mind.

    This is, by the way, the entire reason I made that massive JSON file the other day.

    To include Lunrjs in your site, download the file and put it in your /static/ folder however you want. I have it at /static/js/lunr.js next to my jquery.min.js file. Now when you build your site, the JS file will be copied into place.

    The Code

    Since this is for Hugo, it has two steps. The first is the markdown code to make the post and the second is the template code to do the work.

    Post: Markdown

    The post is called search.md and this is the entirety of it:

    ---
    layout: search
    title: Search Results
    permalink: /search/
    categories: ["Search"]
    tags: ["Index"]
    noToc: true
    ---
    

    Yep. That’s it.

    Template: HTML+GoLang+JS

    I have a template file in layouts/_default/ called search.html and that has all the JS code as well as everything else. This is shamelessly forked from Seb’s example code.

    {{ partial "header.html" . }}
    
    	{{ .Content }}
    
    	<h3>Search:</h3>
    	<input id="search" type="text" id="searchbox" placeholder="Just start typing...">
    
    	<h3>Results:</h3>
    	<ul id="results"></ul>
    
    	<script type="text/javascript" src="/js/lunr.js"></script>
    	<script type="text/javascript">
    	var lunrIndex, $results, pagesIndex;
    
    	function getQueryVariable(variable) {
    		var query = window.location.search.substring(1);
    		var vars = query.split('&');
    
    		for (var i = 0; i < vars.length; i++) {
    			var pair = vars[i].split('=');
    
    			if (pair[0] === variable) {
    				return decodeURIComponent(pair[1].replace(/\+/g, '%20'));
    			}
    		}
    	}
    
    	var searchTerm = getQueryVariable('query');
    
    	// Initialize lunrjs using our generated index file
    	function initLunr() {
    		// First retrieve the index file
    		$.getJSON("/index.json")
    			.done(function(index) {
    				pagesIndex = index;
    				console.log("index:", pagesIndex);
    				lunrIndex = lunr(function() {
    					this.field("title", { boost: 10 });
    					this.field("tags", { boost: 5 });
    					this.field("categories", { boost: 5 });
    					this.field("content");
    					this.ref("uri");
    
    					pagesIndex.forEach(function (page) {
    						this.add(page)
    					}, this)
    				});
    			})
    			.fail(function(jqxhr, textStatus, error) {
    				var err = textStatus + ", " + error;
    				console.error("Error getting Hugo index flie:", err);
    			});
    	}
    
    	// Nothing crazy here, just hook up a listener on the input field
    	function initUI() {
    		$results = $("#results");
    		$("#search").keyup(function() {
    			$results.empty();
    
    			// Only trigger a search when 2 chars. at least have been provided
    			var query = $(this).val();
    			if (query.length < 2) {
    				return;
    			}
    
    			var results = search(query);
    
    			renderResults(results);
    		});
    	}
    
    	/**
    	 * Trigger a search in lunr and transform the result
    	 *
    	 * @param  {String} query
    	 * @return {Array}  results
    	 */
    	function search(query) {
    		return lunrIndex.search(query).map(function(result) {
    				return pagesIndex.filter(function(page) {
    					return page.uri === result.ref;
    				})[0];
    			});
    	}
    
    	/**
    	 * Display the 10 first results
    	 *
    	 * @param  {Array} results to display
    	 */
    	function renderResults(results) {
    		if (!results.length) {
    			return;
    		}
    
    		// Only show the ten first results
    		results.slice(0, 100).forEach(function(result) {
    			var $result = $("<li>");
    			$result.append($("<a>", {
    				href: result.uri,
    				text: "» " + result.title
    			}));
    			$results.append($result);
    		});
    	}
    
    	// Let's get started
    	initLunr();
    
    	$(document).ready(function() {
    		initUI();
    	});
    	</script>
    {{ partial "footer.html" . }}
    

    It’s important to note you will also need to call jQuery but I do that in my header.html file since I have a bit of jQuery I use on every page. If you don’t, then remember to include it up by <script type="text/javascript" src="/js/lunr.js"></script> otherwise nothing will work.

    Caveats

    If you have a large search file, this will make your search page slow to load.

    Also I don’t know how to have a form on one page trigger the search on another, but I’m making baby steps in my javascripting.

  • Hugo Making JSON

    Hugo Making JSON

    While it rhymes with bacon, it’s not at all the same.

    There are a lot of reasons you might want a JSON file output from your static site (I like Hugo). Maybe you’re using Hugo to build out the backend of an API. Maybe you want to have it include a search function. Today I’m going to show you how to have a JSON file created with a complete site archive. The end goal of this example is to have a searchable JSON file that you can use with Lunrjs or Solarjs or anything else of that ilk.

    The Old Way: Node

    Since I was initially doing this to integrate Hugo with Lunr.js, I spent some time wondering how I could make a JSON file and I ran into Lunr Hugo, a fork of Hugo Lunr but with YAML support (which I needed). I actually use a private fork of that, because I wanted to change what it saved, but this is enough to get everyone started.

    To use it, you install it via Node:

    npm install lunr-hugo
    

    Then you add the scripts to your Node package file (normally called package.json):

      "scripts": {
        "test": "echo \"Error: no test specified\" && exit 1",
        "index": "lunr-hugo -i \"site/content/posts/**\" -o site/static/js/search.json"
      },
    

    Change the value of “site/content/” as you see fit. Once installed you can build the index by typing npm run index and it makes the file in the right location.

    The obvious downside to this is I have to run it outside of my normal build process.

    Another Old Way: Grunt

    This idea come from Seb, one of the lead developers for Hugo, and he uses a Grunt script to do this. First you have to install node and things via this command:

    npm install --save-dev grunt string toml conzole

    Next you make a Gruntfile.js file like this:

    var toml = require("toml");
    var S = require("string");
    
    var CONTENT_PATH_PREFIX = "site/content";
    
    module.exports = function(grunt) {
    
        grunt.registerTask("lunr-index", function() {
    
            grunt.log.writeln("Build pages index");
    
            var indexPages = function() {
                var pagesIndex = [];
                grunt.file.recurse(CONTENT_PATH_PREFIX, function(abspath, rootdir, subdir, filename) {
                    grunt.verbose.writeln("Parse file:",abspath);
                    pagesIndex.push(processFile(abspath, filename));
                });
    
                return pagesIndex;
            };
    
            var processFile = function(abspath, filename) {
                var pageIndex;
    
                if (S(filename).endsWith(".html")) {
                    pageIndex = processHTMLFile(abspath, filename);
                } else {
                    pageIndex = processMDFile(abspath, filename);
                }
    
                return pageIndex;
            };
    
            var processHTMLFile = function(abspath, filename) {
                var content = grunt.file.read(abspath);
                var pageName = S(filename).chompRight(".html").s;
                var href = S(abspath)
                    .chompLeft(CONTENT_PATH_PREFIX).s;
                return {
                    title: pageName,
                    href: href,
                    content: S(content).trim().stripTags().stripPunctuation().s
                };
            };
    
            var processMDFile = function(abspath, filename) {
                var content = grunt.file.read(abspath);
                var pageIndex;
                // First separate the Front Matter from the content and parse it
                content = content.split("+++");
                var frontMatter;
                try {
                    frontMatter = toml.parse(content[1].trim());
                } catch (e) {
                    conzole.failed(e.message);
                }
    
                var href = S(abspath).chompLeft(CONTENT_PATH_PREFIX).chompRight(".md").s;
                // href for index.md files stops at the folder name
                if (filename === "index.md") {
                    href = S(abspath).chompLeft(CONTENT_PATH_PREFIX).chompRight(filename).s;
                }
    
                // Build Lunr index for this page
                pageIndex = {
                    title: frontMatter.title,
                    tags: frontMatter.tags,
                    href: href,
                    content: S(content[2]).trim().stripTags().stripPunctuation().s
                };
    
                return pageIndex;
            };
    
            grunt.file.write("site/static/js/lunr/PagesIndex.json", JSON.stringify(indexPages()));
            grunt.log.ok("Index built");
        });
    };
    

    Take note of where it’s saving the files. site/static/js/lunr/PagesIndex.json That’s works for Seb because his set setup has everything Hugo in a /site/ folder.

    To build the file, type grunt lunr-index and off you go.

    The New Way: Output Formats

    All of that sounded really annoying, right? I mean, it’s great but you have structure your site to separate Hugo from the Node folders, and you have to run all those steps outside of Hugo.

    Well there’s good news. You can have this all automatically done if you have Hugo 0.20.0 or greater. In the recent releases, Hugo introduced Output Formats. The extra formats let you spit out your code with RSS feeds, AMP, or (yes) JSON formatting automatically.

    In this example, since I only want to make a master index file with everything, I can do it by telling Hugo that I want my home page, and only my home page, to have a JSON output. In order to do this, I put the following in my config.toml file:

    [outputs]
    	home = [ "HTML", "JSON"]
    	page = [ "HTML"]
    

    If I wanted to have it on more pages, I could do that too. I don’t.

    Next I made a file in my layouts folder called index.json:

    {{- $.Scratch.Add "index" slice -}}
    {{- range where .Site.Pages "Type" "not in"  (slice "page" "json") -}}
    {{- $.Scratch.Add "index" (dict "uri" .Permalink "title" .Title "content" .Plain "tags" .Params.tags "categories" .Params.tags) -}}
    {{- end -}}
    {{- $.Scratch.Get "index" | jsonify -}}
    

    To generate the file, just run a build and it makes a file called index.json in the site root.

    How do you statically build JSON Files?

    Do you have a trick or an idea of how to make building JSON files better? Leave a comment and let me know!

  • Grandchildren Themes

    Grandchildren Themes

    I’m a fan of Genesis themes. They look nice, they’re secure, and they’re well coded. A lot of things I have to reinvent in other themes are done out of the box in Genesis.

    But they’re not perfect.

    Frameworks Beget Children Themes

    The problem with Genesis themes is that if you use them, you’ll end up using a Child Theme and not Genesis. Unlike boilerplate themes like Underscores, you’re not meant to edit the theme itself but make a child theme.

    For the most part, this doesn’t bother me. I don’t generally edit the child themes, except in two cases. Both of my fan sites run highly modified versions of the default themes, and one of them uses the amazing Utility Pro theme by Carrie Dils.

    And that was my problem. I knew Carrie was working on a new version which would have some amazing updates. And I? I had forked her theme.

    Marrying Forks

    Merging my fork to her new theme had, generally, not been an issue. I’ve updated it a dozen times already and I just run a tool to find the diff between the files. I’m a Coda fan, and I use Comparator to check out the differences between files. Doing this is time consuming and annoying, however, and generally leads to people not making changes they should.

    As time went on, I made fewer and fewer changes not because I didn’t want to, but because I had gotten increasingly smarter. Why edit out what I could de-enqueue, for example?

    Grandchildren Plugins

    The solution to my woes was a grandchild. Except instead of a grandchild theme, I made a plugin. Actually I have an mu-plugin called “Site Functions” and in that file is this call:

    if ( 'Utility Pro' == $theme-&gt;name ) {
    include_once( dirname( __FILE__ ) . '/utility-pro/functions.php' );
    }
    

    That file has 300-ish lines of code, which sounds like a lot now that I look at it. Except it boils down to 6 actions and 7 filters:

    add_action( 'wp_enqueue_scripts', array( $this, 'enqueue_scripts' ) );
    add_action( 'genesis_after_entry_content', array( $this, 'genesis_after_entry_content' ), 15 );
    add_action( 'wp_head', array( $this, 'header' ) );
    add_action( 'genesis_before_comment_form', array( $this, 'before_comment_form_policy' ) );
    add_action( 'genesis_before_comments', array( $this, 'before_comments_ads' ) );
    add_action( 'genesis_setup', array( $this, 'theme_setup' ), 20 );
    
    add_filter( 'the_content_more_link', array( $this, 'more_link_text' ) );
    add_filter( 'excerpt_more', array( $this, 'more_link_text' ) );
    add_filter( 'admin_post_thumbnail_html', array( $this, 'admin_post_thumbnail_html' ) );
    add_filter( 'genesis_title_comments', array( $this, 'genesis_title_comments' ) );
    add_filter( 'genesis_comment_list_args', array( $this, 'comment_list_args' ) );
    add_filter( 'comment_form_defaults', array( $this, 'comment_form_defaults' ) );
    add_filter( 'genesis_footer_creds_text', array( $this, 'footer_creds' ), 99 );
    

    Everything I did editing the theme, I can do in those 6 actions and 7 filters. It’s positively amazing. For example, I mentioned dequeueing? I don’t like using Google Fonts if I don’t have to, so I remove them. But I also needed to change the backstretch arguments to properly force my image in the right location, so I can do this:

    wp_dequeue_script( 'utility-pro-fonts' );
    wp_dequeue_script( 'utility-pro-backstretch-args' );
    wp_enqueue_script( 'utility-pro-backstretch-args',  WP_CONTENT_URL . '/mu-plugins/utility-pro/backstretch.args.js', array( 'utility-pro-backstretch' ), '1.3.1', true );
    

    That removes the fonts and backstretch arguments, and then adds my own in. And yes, I know my method of calling mu-plugins is not great. I do it this way because I have symlinks, and plugins_url() manages to call that URL instead of the one I want it to.

    The Benefits

    Doing my code this way means it can’t be deactivated. It also is called by checking the theme, so if that changes then the files stop being called. I keep my own work under version control, letting me go back any time I need to. I’m no longer duplicating Carrie’s work either, speeding up my development time.

    It’s a win all around.