Half-Elf on Tech

Thoughts From a Professional Lesbian

Tag: javascript

  • 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!

  • Sharing Content with Static Sites Dynamically

    Sharing Content with Static Sites Dynamically

    When I wrote how to serve content to Hugo, I did so using something that was mostly static. You see, that code requires someone to push a new version of the Hugo site to rebuild the pages.

    Now let’s be serious, who wants to do that?

    The Concept

    Sadly, you can’t just include a PHP file in Hugo (or any static site builder) and have it echo content. Their whole point is to be static and not change. And my problem is that I immediately ran into a week where I knew the message on the header was going to be changing daily.

    Ew, right? Right. So I looked at that which I should be embracing deeply. Javascript. Or in this case, jQuery and the getJSON call. Yes, that’s right, with jQuery you can call JSON and output it where you want.

    I do not recommend doing this for full page content. This is only vaguely smart if you’re trying to output something small that loads fast and isn’t going to mess up your site if someone has javascript disabled.

    The Code

    <script>
    	$.getJSON( "https://example.com/wp-json/wp/v2/pages/14363", function (json) {
    	    var content = json.content.rendered;
    		document.querySelector('.wpcontent').innerHTML = content;
    	});
    </script>
    
    <div class="utility-bar">
    	<div class="wrap">
    		<section id="text-16" class="widget widget_text">
    			<div class="widget-wrap">
    				<div class="textwidget">
    					<div class="wpcontent"></div>
    				</div>
    			</div>
    		</section>
    	</div>
    </div>
    

    What that code does is it grabs the JSON, sets the variable content to the value of the content’s ‘rendered’ setting. Then using document.querySelector, it tosses in the HTML to my class for wpcontent and I’m done.

  • Datepicker and a Widget

    Datepicker and a Widget

    Last week, I worked on making a plugin that would safely and smartly allow for a date selection, and not permit people to put in junk data. I had my code ‘clean’ and only accepted good data, there was one question remaining. How do I make it look GOOD?

    Let’s be honest, pretty data matters. If things look good, people use them. It’s that simple. This let me play with another aspect of coding that I don’t generally look at. Javascript.

    What Code Do I Need?

    There are a lot of ways to tackle this kind of problem. If you wanted to just list the months and days, and have those be drop-downs, you could do that. The option I went with was to have a calendar date-picker pop up, as I felt that would visually explain what the data should be.

    To do that I needed a date picker jQuery script (which is included in WordPress core) and a second script to format the output.

    My Script

    This part is really small:

    jQuery(function() {
        jQuery( ".datepicker" ).datepicker({
            dateFormat : "mm-dd"
        });
    });
    

    All it does is force the format to be “mm-dd” – so if you picked the date, that’s what it would be.

    Enqueuing the Scripts

    In order to make sure the scripts are only loaded on the widgets page, my enqueue function looks like this:

    	public function admin_enqueue_scripts($hook) {
    		if( $hook !== 'widgets.php' ) return;
    		wp_enqueue_script( 'byq-onthisday', plugins_url( 'js/otd-datepicker.js', __FILE__ ), array( 'jquery-ui-datepicker' ), $this->version, true );
    		wp_enqueue_style( 'jquery-ui', plugins_url( 'css/jquery-ui.css', __FILE__ ), array(), $this->version );
    	}
    

    The CSS is because, by default, WordPress doesn’t include the jquery UI CSS.

    Calling the Scripts

    In the widget class, I have a function for the form output. In there, I have an input field with a class defined as datepicker, which is used by the jquery I wrote above, to know “I’m the one for you!”

    	function form( $instance ) {
    		$instance = wp_parse_args( (array) $instance, $this->defaults );
    		?>
    		<p>
    			<label for="<?php echo esc_attr( $this->get_field_id( 'title' ) ); ?>"><?php _e( 'Title', 'bury-your-queers' ); ?>: </label>
    			<input type="text" id="<?php echo esc_attr( $this->get_field_id( 'title' ) ); ?>" name="<?php echo esc_attr( $this->get_field_name( 'title' ) ); ?>" value="<?php echo esc_attr( $instance['title'] ); ?>" class="widefat" />
    		</p>
    
    		<p>
    			<label for="<?php echo esc_attr( $this->get_field_id( 'date' ) ); ?>"><?php _e( 'Date (Optional)', 'bury-your-queers' ); ?>: </label>
    			<input type="text" id="<?php echo esc_attr( $this->get_field_id( 'date' ) ); ?>" name="<?php echo esc_attr( $this->get_field_name( 'date' ) ); ?>" class="datepicker" value="<?php echo esc_attr( $instance['date'] ); ?>" class="widefat" />
    			<br><em><?php _e( 'If blank, the date will be the current day.', 'bury-your-queers' ); ?></em>
    		</p>
    		<?php
    	}
    

    Making it Pretty

    To be honest, once I got the JS working, I left the default CSS alone. Why? Because I’m a monkey with a crayon when it comes to design. The default worked fine for me:

    The Default Date Picker

    It does make me think that it would be nice if WordPress included their own customize datepicker colors in the admin colors, but I understand why they don’t. Not everyone or even most people will ever need this.

  • Customizing Taxonomies as Dropdowns in Quick Edit

    Customizing Taxonomies as Dropdowns in Quick Edit

    When you go to quick edit for a post, you will automatically see your custom taxonomies as editable fields:

    Custom Taxonomies showing in quick edit!

    But there’s a problem … I don’t want these to be text fields nor do I want them to be checkboxes. When it’s just you running a site, there’s less to worry about with regards to these things. You know what you’re adding, and if you typo, you blame yourself. At the same time, WordPress only has two options for these sorts of things: tags (freeform text) and categories (checkboxes).

    Technically they’re settings for hierarchical, where false is text and true is checkboxes, and I’ve hidden the parent field (a topic for another post). But doing that makes them all check boxes, and I want to restrict people to one and only one checkbox. Except I don’t. The UX of having checkboxes vanish is pretty shitty, and it isn’t logical that you would only check one box.

    Let me explain. In this example, which is practical, I have a taxonomy for human sexuality. I chose taxonomies and not post-meta because that would allow me to sort much more easily on data by groups. Simply, I can grab a list of all people who are flagged as ‘pansexual’ with one function and not have to reinvent the wheel.

    That means I can use radio buttons or a dropdown. I feel a dropdown is a better, cleaner, UX, so that’s what I’m going to do. You can use the same logic here, though, so don’t worry.

    After reading ShibaShake’s documentation on adding in quick edit values I winced. Adding it with quick_edit_custom_box is super easy. The problem is that you have to use an incredibly weird amount of javascript to get the post ID and pass data back and forth. As weird and annoying as that was, it actually works. The example, of course, is comparing a CPT, where as I am using a custom taxonomy, so my own code was a little crazier.

    Remove the taxonomy from Quick Edit

    To do this starts out sounding like the stupidest idea ever, but you want to set show_in_quick_edit to false in your taxonomy. That does what it sounds like, removing the item from the quick edit page. Obviously now I have to add it back.

    It’s important to note that if you don’t have any custom columns in your post list view, the rest of this won’t work. I do, and since part two of all this will be about being able to edit those custom columns, I don’t have to worry. If you do, here’s how you add a fake column… Ready? You add and remove the column. I know, I know, WP can be weird:

    add_filter('manage_posts_columns', 'lezchars_add_fake_column', 10, 2);
    function lezchars_add_fake_column( $posts_columns, $post_type ) {
        $posts_columns['lez_fakeit'] = 'Fake Column (Invisible)';
        return $posts_columns;
    }
    add_filter('manage_edit-post_columns', 'lezchars_remove_fake_column');
    function remove_dummy_column( $posts_columns ) {
        unset($posts_columns['lez_fakeit']);
        return $posts_columns;
    }
    

    Like I said, I know it’s weird.

    Add the Quick Edit Box

    Since I know I’m going to be building out a few more of these, and one is a cross-relational CPT to CPT drama, I’ve broken mine out into a switch/case basis.

    // Add quick Edit boxes
    add_action('quick_edit_custom_box',  'lezchars_quick_edit_add', 10, 2);
    function lezchars_quick_edit_add($column_name, $post_type) {
    	switch ( $column_name ) {
    		case 'shows':
    			// Multiselect - CPT where characters may have multiple
    			break;
    		case 'roletype':
    			// Single Select - Custom Taxonomy
    			break;
    		case 'taxonomy-lez_sexuality':
    			?>
    			<fieldset class="inline-edit-col-left">
    			<div class="inline-edit-col">
    			<span class="title">Sexual Orientation</span>
    				<input type="hidden" name="lez_sexuality_noncename" id="lez_sexuality_noncename" value="" />
    				<?php 
    				$terms = get_terms( array( 'taxonomy' => 'lez_sexuality','hide_empty' => false ) );
    				?>
    				<select name='terms_lez_sexuality' id='terms_lez_sexuality'>
    					<option class='lez_sexuality-option' value='0'>(Undefined)</option>
    					<?php
    					foreach ($terms as $term) {
    						echo "<option class='lez_sexuality-option' value='{$term->name}'>{$term->name}</option>\n";
    					}
    						?>
    				</select>
    			</div>
    			</fieldset>
    			<?php
    		break;
    	}
    }
    

    This is all pretty basic stuff. I’m simply making a form for a drop-down based on the contents of my taxonomy. If you want to make it radio buttons, do that.

    Saving The Changes

    Now we’re getting a little weirder. We need to save our changes, but only if it’s not an auto-save, and if it’s the correct post type (post_type_characters for this page), and if the user can edit the page:

    add_action('save_post', 'lezchars_quick_edit_save');
    function lezchars_quick_edit_save($post_id) {
        // Criteria for not saving: Auto-saves, not post_type_characters, can't edit
        if ( ( defined('DOING_AUTOSAVE') && DOING_AUTOSAVE ) || ( 'post_type_characters' != $_POST['post_type'] ) || !current_user_can( 'edit_page', $post_id ) ) {
    		return $post_id;
    	}
    
    	$post = get_post($post_id);
    
    	// Lez Sexuality
    	if ( isset($_POST['terms_lez_sexuality']) && ($post->post_type != 'revision') ) {
    		$lez_sexuality_term = esc_attr($_POST['terms_lez_sexuality']);
    		$term = term_exists( $lez_sexuality_term, 'lez_sexuality');
    		if ( $term !== 0 && $term !== null) {
    			wp_set_object_terms( $post_id, $lez_sexuality_term, 'lez_sexuality' );
    		}
    	}
    }
    

    The two checks going on here are first to be sure it’s the right POST action to save on, and then to only update if the term already exists. If you wanted to append terms instead of replace, you could add a ‘true’ param to wp_set_object_terms, however I want only set one sexuality per person in this case.

    At this point, the code actually works!

    The new dropdown

    Changing the current selection for the dropdown

    There’s one problem though. If you’re working along with me this far, you’ll have noticed that the default selection is always ‘(Undefined)’ and that’s not what we want. The extension of this problem is we have to use bloody javascript to edit it. Damn it.

    // Javascript to change 'defaults'
    add_action('admin_footer', 'lezchars_quick_edit_js');
    function lezchars_quick_edit_js() {
    	global $current_screen;
    	if ( ($current_screen->id !== 'edit-post_type_characters') || ($current_screen->post_type !== 'post_type_characters') ) return;
    	?>
    	<script type="text/javascript">
    	<!--
    	function set_inline_lez_sexuality( widgetSet, nonce ) {
    		// revert Quick Edit menu so that it refreshes properly
    		inlineEditPost.revert();
    		var widgetInput = document.getElementById('terms_lez_sexuality');
    		var nonceInput = document.getElementById('lez_sexuality_noncename');
    		nonceInput.value = nonce;
    
    		// check option manually
    		for (i = 0; i < widgetInput.options.length; i++) {
    			if (widgetInput.options[i].value == widgetSet) {
    				widgetInput.options[i].setAttribute("selected", "selected");
    			} else { widgetInput.options[i].removeAttribute("selected"); }
    		}
    	}
    	//-->
    	</script>
    	<?php
    }
    
    // Calls the JS in the previous function
    add_filter('post_row_actions', 'lezchars_quick_edit_link', 10, 2);
    
    function lezchars_quick_edit_link($actions, $post) {
    	global $current_screen;
    	if (($current_screen->id != 'edit-post_type_characters') || ($current_screen->post_type != 'post_type_characters')) return $actions;
    
    	$lez_nonce = wp_create_nonce( 'lez_sexuality_'.$post->ID);
    	$lez_sex   = wp_get_post_terms( $post->ID, 'lez_sexuality', array( 'fields' => 'all' ) );
    
    	$actions['inline hide-if-no-js'] = '<a href="#" class="editinline" title="';
    	$actions['inline hide-if-no-js'] .= esc_attr( __( 'Edit this item inline' ) ) . '" ';
    	$actions['inline hide-if-no-js'] .= " onclick=\"set_inline_lez_sexuality('{$lez_sex[0]->name}', '{$lez_nonce}')\">";
    	$actions['inline hide-if-no-js'] .= __( 'Quick&nbsp;Edit' );
    	$actions['inline hide-if-no-js'] .= '</a>';
    	return $actions;
    }
    

    Please don’t ask me to explain that. And yes, I know we should be using Unobtrusive Javascript and hooking into the DOM instead, but I don’t yet know how to do that.

    I do know that if I wanted to add in multiple checks, one way is to duplicate the function set_inline_lez_sexuality( widgetSet, nonce ) and rename it to set_inline_lez_gender and then extend the actions like this:

    $actions['inline hide-if-no-js'] .= " onclick=\"set_inline_lez_sexuality('{$sex_terms[0]->name}', '{$sex_nonce}');set_inline_lez_gender('{$gender_terms[0]->name}', '{$gender_nonce}')\">";
    

    There are more combinations and concatenations one can do here. Knock yourself out. It’s enough javascript for me today!